@jini-ai/core 0.4.9 → 0.6.11
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 +23 -2
- package/README.md +5 -0
- package/dist/redact.d.ts +28 -0
- package/dist/redact.js +58 -1
- package/dist/settings/agent-tools.js +9 -6
- package/dist/settings/errors.d.ts +13 -0
- package/dist/settings/errors.js +15 -0
- package/dist/settings/index.d.ts +2 -2
- package/dist/settings/index.js +1 -1
- package/dist/settings/ports.d.ts +7 -1
- package/dist/settings/tool-registrations.js +9 -3
- package/dist/settings/write-service.d.ts +23 -4
- package/dist/settings/write-service.js +38 -5
- package/dist/tool-input-normalization.d.ts +4 -3
- package/dist/tool-input-normalization.js +48 -10
- package/dist/tool-registry.d.ts +3 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,28 @@
|
|
|
1
1
|
# @jini-ai/core
|
|
2
2
|
|
|
3
|
+
## 0.6.11
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- OAuth accepts a parent protected-resource identifier and forwards the refresh resource; settings definitions are filtered; scope selectors are never id shorthand; secret-named URL parameters are masked by default in redaction.
|
|
8
|
+
- Tool inputs apply a sole near-miss and declared input aliases; plugin-load outcomes are reported; `web_fetch` reads SVG/JS as text; `content_read` suggests its resource first; the browser falls back to system Chrome.
|
|
9
|
+
|
|
10
|
+
## 0.6.10
|
|
11
|
+
|
|
12
|
+
### Patch Changes
|
|
13
|
+
|
|
14
|
+
- 22f0619: Export baseline agent-failure token patterns, credential-directory deny
|
|
15
|
+
segments, and local-owner proxy markers for reuse by consumers.
|
|
16
|
+
Preserve existing vocabulary and redaction behavior.
|
|
17
|
+
- 82ed2d3: Expose the React-free fetch-query contracts/cache at @jini-ai/core/fetch; UI keeps deprecated direct type re-exports. Build core before UI and publish before consumers adopt the subpath.
|
|
18
|
+
- 5a3de86: Add optional `expectedRevisionSeq` options to settings `set(required, options)` and
|
|
19
|
+
`clear(required, options)`. Compare the addressed scope's stored value revision under
|
|
20
|
+
the existing transaction's ledger lock, throwing `SettingsRevisionConflictError`
|
|
21
|
+
with expected and actual sequences on mismatch and rolling back the write and revision.
|
|
22
|
+
Revision 0 represents a missing row; cleared rows retain their revision. Omitting
|
|
23
|
+
the option preserves unconditional writes. Available through `@jini-ai/core/settings`
|
|
24
|
+
and the existing `@jini-ai/cms/settings` compatibility re-export.
|
|
25
|
+
|
|
3
26
|
## 0.4.9 — 2026-10-22
|
|
4
27
|
|
|
5
28
|
- 2026-10-22: Provider registry, ObjectStore contract with object_store registry and deferObjectStore, default-on batch tool eligibility, coverage.
|
|
@@ -56,7 +79,6 @@
|
|
|
56
79
|
- Move the domain-independent catalog/input/schema/risk/confirmation registration kit into the root entry, with its behavior contracts. Domain-specific authorization and denial errors remain outside the kernel.
|
|
57
80
|
- Use the existing named argument surface directly; primitive, Result and tool catalog types remain canonical in core.
|
|
58
81
|
|
|
59
|
-
|
|
60
82
|
### Shared kernel
|
|
61
83
|
|
|
62
84
|
- Add `./primitives` with canonical time, ID, JSON, HTTP, logger and result contracts,
|
|
@@ -80,7 +102,6 @@
|
|
|
80
102
|
- Origin diagnostics use `Pick<Logger, 'warn'>` rather than a duplicate logger interface.
|
|
81
103
|
The alias name `OriginValidationLoggerPort` remains available with the shared shape.
|
|
82
104
|
|
|
83
|
-
|
|
84
105
|
- Add transport-neutral gated mutations with required token generation/TTL and async
|
|
85
106
|
authorization, identity and token-store ports; preserve verified-plan handoff and
|
|
86
107
|
single-use redemption, with atomic explicit expiry and duplicate-issuance rejection.
|
package/README.md
CHANGED
|
@@ -44,6 +44,11 @@ peer used only by `@jini-ai/core/settings/express`; install it in hosts mounting
|
|
|
44
44
|
|
|
45
45
|
## Usage
|
|
46
46
|
|
|
47
|
+
The root exports baseline security vocabulary: `AGENT_FAILURE_SECRET_SHAPE_RE`,
|
|
48
|
+
`CREDENTIAL_FILE_DENY_SEGMENTS`, `LOCAL_OWNER_PROXY_MARKER_PREFIXES`, and
|
|
49
|
+
`LOCAL_OWNER_PROXY_MARKER_HEADERS`. Consumers apply these in their existing failure-redaction,
|
|
50
|
+
guarded-file, and local-owner admission policies; the existing redactors are unchanged.
|
|
51
|
+
|
|
47
52
|
```ts
|
|
48
53
|
import { bindings, definePack, createDaemon, token, createToolRegistry, registerPackTools } from '@jini-ai/core';
|
|
49
54
|
|
package/dist/redact.d.ts
CHANGED
|
@@ -38,6 +38,34 @@ export interface SecretShapePattern {
|
|
|
38
38
|
}
|
|
39
39
|
export declare const PEM_PATTERN_NAME = "PEM private key block";
|
|
40
40
|
export declare const SECRET_SHAPE_PATTERNS: readonly SecretShapePattern[];
|
|
41
|
+
/** Broad token shapes for agent-failure text; consumers retain their plain [REDACTED] pipeline. */
|
|
42
|
+
export declare const AGENT_FAILURE_SECRET_SHAPE_RE: RegExp;
|
|
43
|
+
/**
|
|
44
|
+
* Exact path SEGMENT names refused at any depth, matched case-insensitively against each segment
|
|
45
|
+
* of the caller-supplied path (never a substring — a directory named `not-secrets-actually` is not
|
|
46
|
+
* a match). `secrets` is the owner's explicit example: whatever a site or app tree names its
|
|
47
|
+
* credentials folder (`sites/**\/secrets/`, `apps/admin/**\/secrets/`, or anywhere else), it never
|
|
48
|
+
* becomes reachable through this domain. A denied segment is refused by the guarded reader
|
|
49
|
+
* AND skipped entirely by the `fs_list_files` walk — its contents are never even enumerated,
|
|
50
|
+
* not merely refused once named. Besides `secrets`, the set names the
|
|
51
|
+
* well-known credential-store directories a home-directory `custom` root can expose: `.aws`
|
|
52
|
+
* (`credentials`), `.docker` (`config.json` with registry auth), `.kube` (`config`), and
|
|
53
|
+
* `gcloud`/`gh` for `.config/gcloud/application_default_credentials.json` and
|
|
54
|
+
* `.config/gh/hosts.yml`. Denied by SEGMENT rather than by leaf filename on purpose: `config`,
|
|
55
|
+
* `config.json`, and `hosts.yml` are generic names that appear harmlessly elsewhere, so denying the
|
|
56
|
+
* directory is the narrower of the two mistakes.
|
|
57
|
+
*
|
|
58
|
+
* A host's install-wide site-key directory (2026-09-24) needs the same protection; the host appends it.
|
|
59
|
+
* Production stores it beside individual site folders on a durable volume; local/dev stores
|
|
60
|
+
* it below the home directory, reachable through an operator-set `custom` root. Its key backs
|
|
61
|
+
* site-key administration, webhook/newsletter signing secrets, and permission checks, so the
|
|
62
|
+
* agent must never read it from disk. Denying the whole directory segment, rather than only
|
|
63
|
+
* a generated key filename, also prevents `fs_list_files` from enumerating its contents — the
|
|
64
|
+
* same "never even advertise it" property `secrets` gets above.
|
|
65
|
+
*/
|
|
66
|
+
export declare const CREDENTIAL_FILE_DENY_SEGMENTS: readonly string[];
|
|
67
|
+
export declare const LOCAL_OWNER_PROXY_MARKER_PREFIXES: readonly string[];
|
|
68
|
+
export declare const LOCAL_OWNER_PROXY_MARKER_HEADERS: readonly string[];
|
|
41
69
|
export interface SecretRedactionPattern {
|
|
42
70
|
name: string;
|
|
43
71
|
regex: RegExp;
|
package/dist/redact.js
CHANGED
|
@@ -52,6 +52,40 @@ export const SECRET_SHAPE_PATTERNS = [
|
|
|
52
52
|
{ name: "Fastmail app password (fm2_)", pattern: /(?<![A-Za-z0-9+/=])fm2_[A-Za-z0-9+/=]{20,}/ },
|
|
53
53
|
{ name: PEM_PATTERN_NAME, pattern: /-----BEGIN [A-Z ]*PRIVATE KEY-----/ },
|
|
54
54
|
];
|
|
55
|
+
/** Broad token shapes for agent-failure text; consumers retain their plain [REDACTED] pipeline. */
|
|
56
|
+
export const AGENT_FAILURE_SECRET_SHAPE_RE = /\b(?:AIza[\w-]+|AQ\.[\w.\/-]+|sk-[\w.-]+|gh[pousr]_[\w]+|github_pat_[\w]+|ya29\.[\w.-]+|eyJ[\w-]+\.[\w-]+(?:\.[\w-]+)?)/g;
|
|
57
|
+
/**
|
|
58
|
+
* Exact path SEGMENT names refused at any depth, matched case-insensitively against each segment
|
|
59
|
+
* of the caller-supplied path (never a substring — a directory named `not-secrets-actually` is not
|
|
60
|
+
* a match). `secrets` is the owner's explicit example: whatever a site or app tree names its
|
|
61
|
+
* credentials folder (`sites/**\/secrets/`, `apps/admin/**\/secrets/`, or anywhere else), it never
|
|
62
|
+
* becomes reachable through this domain. A denied segment is refused by the guarded reader
|
|
63
|
+
* AND skipped entirely by the `fs_list_files` walk — its contents are never even enumerated,
|
|
64
|
+
* not merely refused once named. Besides `secrets`, the set names the
|
|
65
|
+
* well-known credential-store directories a home-directory `custom` root can expose: `.aws`
|
|
66
|
+
* (`credentials`), `.docker` (`config.json` with registry auth), `.kube` (`config`), and
|
|
67
|
+
* `gcloud`/`gh` for `.config/gcloud/application_default_credentials.json` and
|
|
68
|
+
* `.config/gh/hosts.yml`. Denied by SEGMENT rather than by leaf filename on purpose: `config`,
|
|
69
|
+
* `config.json`, and `hosts.yml` are generic names that appear harmlessly elsewhere, so denying the
|
|
70
|
+
* directory is the narrower of the two mistakes.
|
|
71
|
+
*
|
|
72
|
+
* A host's install-wide site-key directory (2026-09-24) needs the same protection; the host appends it.
|
|
73
|
+
* Production stores it beside individual site folders on a durable volume; local/dev stores
|
|
74
|
+
* it below the home directory, reachable through an operator-set `custom` root. Its key backs
|
|
75
|
+
* site-key administration, webhook/newsletter signing secrets, and permission checks, so the
|
|
76
|
+
* agent must never read it from disk. Denying the whole directory segment, rather than only
|
|
77
|
+
* a generated key filename, also prevents `fs_list_files` from enumerating its contents — the
|
|
78
|
+
* same "never even advertise it" property `secrets` gets above.
|
|
79
|
+
*/
|
|
80
|
+
// Generic credential-store segments only. A host adds its own product-specific key directory
|
|
81
|
+
// (e.g. its install-wide site-key folder) when it builds its deny-list; core stays product-neutral.
|
|
82
|
+
export const CREDENTIAL_FILE_DENY_SEGMENTS = ["secrets", ".aws", ".docker", ".kube", "gcloud", "gh"];
|
|
83
|
+
// A local tunnel has a loopback socket and may rewrite Host; refuse marker presence, even empty.
|
|
84
|
+
export const LOCAL_OWNER_PROXY_MARKER_PREFIXES = ["x-forwarded-", "x-envoy-", "x-ms-"];
|
|
85
|
+
export const LOCAL_OWNER_PROXY_MARKER_HEADERS = [
|
|
86
|
+
"forwarded", "x-real-ip", "via", "cf-connecting-ip", "true-client-ip", "x-original-host",
|
|
87
|
+
"x-client-ip", "x-cluster-client-ip", "fastly-client-ip", "tailscale-user-login", "x-original-forwarded-for",
|
|
88
|
+
];
|
|
55
89
|
// Order matters: list specific rules before more general ones. Langfuse
|
|
56
90
|
// keys (`sk-lf-...`) would otherwise be eaten by the generic `sk-...`
|
|
57
91
|
// rule and labeled as a generic OpenAI-style key.
|
|
@@ -147,7 +181,8 @@ function isLuhnValid(digits) {
|
|
|
147
181
|
// correlation ID uses uppercase hex and is meant to reach the model and be quoted to an
|
|
148
182
|
// operator; delegated-tool-calls is the route segment named in delegated tool failures.
|
|
149
183
|
// Matching the ENTIRE run keeps a secret glued onto either spelling redactable.
|
|
150
|
-
|
|
184
|
+
const SECRET_LABELS = 'authorization|bearer|api[-_]?key|access[-_]?token|refresh[-_]?token|client[-_]?secret|password|secret|cookie';
|
|
185
|
+
export const LABELED_SECRET_RE = new RegExp(`\\b(${SECRET_LABELS})\\b\\s*[:=]\\s*\\S+`, 'gi');
|
|
151
186
|
export const OPAQUE_TOKEN_RE = /[A-Za-z0-9_-]{20,}/g;
|
|
152
187
|
export const NON_SECRET_TOKEN_RE = /^(?:ERR-[0-9A-F]{4}(?:-[0-9A-F]{4}){3}|delegated-tool-calls|[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+)$/;
|
|
153
188
|
// Runtime markers must survive a second pass; the bounded sanitizer deliberately retains
|
|
@@ -164,6 +199,15 @@ const PROVIDER_API_KEY_QUERY = /([?&]key=)([^&\s]+)/gi;
|
|
|
164
199
|
// readable for debugging without exposing credentials that authorize access to the resource.
|
|
165
200
|
const URL_CREDENTIALS_RE = /(\b[a-z][a-z0-9+.-]*:\/\/)([^/\s?#@]+)@/gi;
|
|
166
201
|
const SIGNED_QUERY_RE = /([?&](?:signature|sig|x-amz-signature|x-goog-signature|access_token|refresh_token)=)([^&#\s,;"']+)/gi;
|
|
202
|
+
// A secret word must END a name segment (split at . _ - or the name's end): api_key, apiKey,
|
|
203
|
+
// authtoken, X-Amz-Signature and custom.Token-value are masked, while author, keyword, design,
|
|
204
|
+
// signup and page stay readable. Names that merely end in a secret word (zipcode, error_code,
|
|
205
|
+
// monkey) are masked too; over-masking a URL value is the cheaper failure. Empty values stay.
|
|
206
|
+
// Match through a trailing fragment; only '&', quotes, parentheses or whitespace end the value.
|
|
207
|
+
// Both name runs are bounded so a long separator-free run costs linear time, not quadratic.
|
|
208
|
+
const URL_SECRET_PARAM_RE = /[?&#;]([\w.-]{0,128}?(?:token|key|secret|signature|sig|code|password|passwd|pwd|auth|authorization|credential)s?(?=[._=-])[\w.-]{0,128})=[^&\s"'<>()]+/gi;
|
|
209
|
+
// A parameter the labeled rule already names (password, client_secret, ...) keeps that category.
|
|
210
|
+
const LABELED_URL_PARAM_NAME_RE = new RegExp(`(?:^|[.-])(?:${SECRET_LABELS})$`, 'i');
|
|
167
211
|
// Only an entire marker is already masked. A secret glued onto marker-like text still needs
|
|
168
212
|
// masking; a substring check would preserve credential suffixes in echoed header/query values.
|
|
169
213
|
function isMaskedValue(value) {
|
|
@@ -267,6 +311,9 @@ function redactKeyedCredentials({ text, marker }) {
|
|
|
267
311
|
out = out.replace(API_KEY_QUERY, (match, prefix) => isMaskedValue(match.slice(prefix.length)) ? match : `${prefix}${marker('api_key_query')}`);
|
|
268
312
|
out = out.replace(SHORT_BEARER_RE, () => marker('bearer_token'));
|
|
269
313
|
out = out.replace(PROVIDER_API_KEY_HEADER, (match, name, value) => isMaskedValue(value) ? match : `${name}: ${marker('api_key_header')}`);
|
|
314
|
+
// Sweep other URL parameters before the general labeled rule can consume harmless
|
|
315
|
+
// neighboring parameters (`password=x&page=2` used to lose `&page=2`).
|
|
316
|
+
out = redactUrlSecretParams({ text: out, marker });
|
|
270
317
|
return out.replace(MASK_AWARE_LABELED_SECRET_RE, (match, _label, value) => {
|
|
271
318
|
// Replace only the captured value, retaining labels even when no whitespace separates
|
|
272
319
|
// them. Glued credential suffixes fail the complete-marker check and remain maskable.
|
|
@@ -275,6 +322,16 @@ function redactKeyedCredentials({ text, marker }) {
|
|
|
275
322
|
return `${match.slice(0, -value.length)}${marker('labeled_secret')}`;
|
|
276
323
|
});
|
|
277
324
|
}
|
|
325
|
+
/** Secret-named URL query/fragment values; values already masked above keep their category. */
|
|
326
|
+
function redactUrlSecretParams({ text, marker }) {
|
|
327
|
+
return text.replace(URL_SECRET_PARAM_RE, (match, name) => {
|
|
328
|
+
const separator = match.indexOf('=') + 1;
|
|
329
|
+
if (isMaskedValue(match.slice(separator)))
|
|
330
|
+
return match;
|
|
331
|
+
const category = LABELED_URL_PARAM_NAME_RE.test(name) ? 'labeled_secret' : 'url_secret_param';
|
|
332
|
+
return `${match.slice(0, separator)}${marker(category)}`;
|
|
333
|
+
});
|
|
334
|
+
}
|
|
278
335
|
/** Email/IPv4/phone rules, then Luhn-checked card candidates. */
|
|
279
336
|
function redactPii({ text, marker }) {
|
|
280
337
|
return applyPatterns({ text, marker, patterns: PII_PATTERNS }).replace(CARD_CANDIDATE, (match) => {
|
|
@@ -49,13 +49,16 @@ import { AGENT_WRITABLE_PREFERENCE_IDS, AGENT_WRITABLE_PREFERENCES } from "./age
|
|
|
49
49
|
* write entries this file documents but never wires (see file header) carry no schema at all,
|
|
50
50
|
* since one is never published for a tool the model never sees.
|
|
51
51
|
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
const
|
|
52
|
+
* Optional filters narrow the caller's own (boot-wired) workspace's site-owned partition plus
|
|
53
|
+
* the shared platform partition; they never select a different workspace. */
|
|
54
|
+
const LIST_DEFINITIONS_SCHEMA = {
|
|
55
55
|
type: "object",
|
|
56
56
|
additionalProperties: false,
|
|
57
57
|
required: [],
|
|
58
|
-
properties: {
|
|
58
|
+
properties: {
|
|
59
|
+
search: { type: "string", description: "Case-insensitive text filter over the full 'namespace.key' name." },
|
|
60
|
+
namespace: { type: "string", description: "Only definitions in this exact namespace." },
|
|
61
|
+
},
|
|
59
62
|
};
|
|
60
63
|
/** Shared by `settings_get_effective`/`settings_get_raw`: naming a DIFFERENT principal than the
|
|
61
64
|
* caller requires `settings.user.read` (checked by the handler, not expressed in this schema). */
|
|
@@ -138,10 +141,10 @@ export function getSettingsAgentToolCatalog() {
|
|
|
138
141
|
return [
|
|
139
142
|
{
|
|
140
143
|
name: "settings_list_definitions",
|
|
141
|
-
description: "Lists
|
|
144
|
+
description: "Lists active setting definitions visible to this workspace (platform core/theme definitions plus this workspace's own site-owned definitions), optionally filtered by search text and exact namespace: namespace, key, owner kind, scope bitmask, status, version, schema, agentWritable and confirmationRequired. Use before writing a setting. Metadata only — never a value.",
|
|
142
145
|
sideEffects: "none",
|
|
143
146
|
authorization: { permission: "settings.read.definitions" },
|
|
144
|
-
inputSchema:
|
|
147
|
+
inputSchema: LIST_DEFINITIONS_SCHEMA,
|
|
145
148
|
},
|
|
146
149
|
{
|
|
147
150
|
name: "settings_get_effective",
|
|
@@ -18,6 +18,19 @@ export declare class SecretNotSupportedError extends Error {
|
|
|
18
18
|
}
|
|
19
19
|
export declare class ValueValidationFailedError extends Error {
|
|
20
20
|
}
|
|
21
|
+
/**
|
|
22
|
+
* A scoped value changed since the caller's snapshot; read again before retrying.
|
|
23
|
+
* Both sequences refer to the addressed value row, never the ledger's global watermark.
|
|
24
|
+
* @example if (error instanceof SettingsRevisionConflictError) await refreshSnapshot();
|
|
25
|
+
*/
|
|
26
|
+
export declare class SettingsRevisionConflictError extends Error {
|
|
27
|
+
readonly expectedRevisionSeq: number;
|
|
28
|
+
readonly actualRevisionSeq: number;
|
|
29
|
+
constructor(required: {
|
|
30
|
+
expectedRevisionSeq: number;
|
|
31
|
+
actualRevisionSeq: number;
|
|
32
|
+
}, optional?: ErrorOptions);
|
|
33
|
+
}
|
|
21
34
|
export declare class RenameRetypeConflictError extends Error {
|
|
22
35
|
}
|
|
23
36
|
export declare class AliasDepthExceededError extends Error {
|
package/dist/settings/errors.js
CHANGED
|
@@ -18,6 +18,21 @@ export class SecretNotSupportedError extends Error {
|
|
|
18
18
|
}
|
|
19
19
|
export class ValueValidationFailedError extends Error {
|
|
20
20
|
}
|
|
21
|
+
/**
|
|
22
|
+
* A scoped value changed since the caller's snapshot; read again before retrying.
|
|
23
|
+
* Both sequences refer to the addressed value row, never the ledger's global watermark.
|
|
24
|
+
* @example if (error instanceof SettingsRevisionConflictError) await refreshSnapshot();
|
|
25
|
+
*/
|
|
26
|
+
export class SettingsRevisionConflictError extends Error {
|
|
27
|
+
expectedRevisionSeq;
|
|
28
|
+
actualRevisionSeq;
|
|
29
|
+
constructor(required, optional = {}) {
|
|
30
|
+
super(`Setting revision changed: expected ${required.expectedRevisionSeq}, found ${required.actualRevisionSeq}.`, optional);
|
|
31
|
+
this.name = "SettingsRevisionConflictError";
|
|
32
|
+
this.expectedRevisionSeq = required.expectedRevisionSeq;
|
|
33
|
+
this.actualRevisionSeq = required.actualRevisionSeq;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
21
36
|
export class RenameRetypeConflictError extends Error {
|
|
22
37
|
}
|
|
23
38
|
export class AliasDepthExceededError extends Error {
|
package/dist/settings/index.d.ts
CHANGED
|
@@ -11,11 +11,11 @@
|
|
|
11
11
|
* `SettingsRepoPort` this barrel exports.
|
|
12
12
|
*/
|
|
13
13
|
export { SCOPE_BIT, type SettingScope, type SettingOwnerKind, type DefinitionStatus, type ValueState, type RevisionEntityKind, type RevisionOp, type SettingValueSchema, type SettingDefinitionRecord, type SettingValueRecord, type SettingRevisionRecord, type SettingScopeContext, } from "./types.js";
|
|
14
|
-
export { DefinitionInvalidError, ScopeNotAllowedError, SecretNotSupportedError, ValueValidationFailedError, RenameRetypeConflictError, AliasDepthExceededError, DefinitionTombstonedError, DefinitionNotFoundError, PurgeRequiredError, ForbiddenError, PrincipalNotFoundError, } from "./errors.js";
|
|
14
|
+
export { DefinitionInvalidError, ScopeNotAllowedError, SecretNotSupportedError, ValueValidationFailedError, SettingsRevisionConflictError, RenameRetypeConflictError, AliasDepthExceededError, DefinitionTombstonedError, DefinitionNotFoundError, PurgeRequiredError, ForbiddenError, PrincipalNotFoundError, } from "./errors.js";
|
|
15
15
|
export type { SettingsRepoPort } from "./ports.js";
|
|
16
16
|
export { InMemorySettingsRepo } from "./repo.memory.js";
|
|
17
17
|
export { type DefinitionInput, validateDefinitionInput, validateValueAgainstSchema, registerCoercer, invalidateDefinitionNamespaceCache, invalidateWorkspaceSettingsCache, resolveDefinitionRaw, resolveDefinition, type ResolvedSetting, getEffective, } from "./settings.js";
|
|
18
|
-
export { type AuthorizeFn, type SettingsWriteServiceDeps, deriveRequiredPermission, type RegisterDefinitionsRequired, registerDefinitions, type SetValueRequired, set, type ClearValueRequired, clear, type ResetNamespaceRequired, resetNamespace, type RenameDefinitionRequired, renameDefinition, type RetypeDefinitionRequired, retypeDefinition, type ReconcileDefinitionDefaultRequired, reconcileDefinitionDefault, type DeprecateDefinitionRequired, deprecateDefinition, type TombstoneDefinitionRequired, tombstoneDefinition, } from "./write-service.js";
|
|
18
|
+
export { type AuthorizeFn, type SettingsWriteServiceDeps, deriveRequiredPermission, type RegisterDefinitionsRequired, registerDefinitions, type SetValueRequired, type SettingsValueWriteOptions, set, type ClearValueRequired, clear, type ResetNamespaceRequired, resetNamespace, type RenameDefinitionRequired, renameDefinition, type RetypeDefinitionRequired, retypeDefinition, type ReconcileDefinitionDefaultRequired, reconcileDefinitionDefault, type DeprecateDefinitionRequired, deprecateDefinition, type TombstoneDefinitionRequired, tombstoneDefinition, } from "./write-service.js";
|
|
19
19
|
export { type PurgeServiceDeps, type PurgeTenantSettingsRequired, purgeTenantSettings, } from "./purge-service.js";
|
|
20
20
|
export { type ChangeFeedViewer, isRevisionVisibleTo, type ChangeFeedBatch, collectChangedNamespaces, } from "./change-feed.js";
|
|
21
21
|
export { type DefinitionOpRequestItem, type DefinitionOpContext, type DefinitionOpHandler, NON_REGISTER_DEFINITION_OP_NAMES, type NonRegisterDefinitionOp, parseNonRegisterDefinitionOp, NON_REGISTER_DEFINITION_OPS, } from "./definitions-dispatch.js";
|
package/dist/settings/index.js
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* `SettingsRepoPort` this barrel exports.
|
|
12
12
|
*/
|
|
13
13
|
export { SCOPE_BIT, } from "./types.js";
|
|
14
|
-
export { DefinitionInvalidError, ScopeNotAllowedError, SecretNotSupportedError, ValueValidationFailedError, RenameRetypeConflictError, AliasDepthExceededError, DefinitionTombstonedError, DefinitionNotFoundError, PurgeRequiredError, ForbiddenError, PrincipalNotFoundError, } from "./errors.js";
|
|
14
|
+
export { DefinitionInvalidError, ScopeNotAllowedError, SecretNotSupportedError, ValueValidationFailedError, SettingsRevisionConflictError, RenameRetypeConflictError, AliasDepthExceededError, DefinitionTombstonedError, DefinitionNotFoundError, PurgeRequiredError, ForbiddenError, PrincipalNotFoundError, } from "./errors.js";
|
|
15
15
|
export { InMemorySettingsRepo } from "./repo.memory.js";
|
|
16
16
|
export { validateDefinitionInput, validateValueAgainstSchema, registerCoercer, invalidateDefinitionNamespaceCache, invalidateWorkspaceSettingsCache, resolveDefinitionRaw, resolveDefinition, getEffective, } from "./settings.js";
|
|
17
17
|
export { deriveRequiredPermission, registerDefinitions, set, clear, resetNamespace, renameDefinition, retypeDefinition, reconcileDefinitionDefault, deprecateDefinition, tombstoneDefinition, } from "./write-service.js";
|
package/dist/settings/ports.d.ts
CHANGED
|
@@ -68,7 +68,13 @@ export interface SettingsRepoPort {
|
|
|
68
68
|
principalId: UUID;
|
|
69
69
|
settingId: UUID;
|
|
70
70
|
}): Promise<void>;
|
|
71
|
-
/**
|
|
71
|
+
/**
|
|
72
|
+
* Appends a revision and returns its assigned `seq` (used to stamp the paired value row).
|
|
73
|
+
* Inside a transaction, serialize revision writers until that enclosing transaction ends:
|
|
74
|
+
* `set`/`clear` compare the stored value's seq AFTER this call, so the lock must cover the
|
|
75
|
+
* comparison and value save too. SQLite's write transaction and Postgres's transaction-scoped
|
|
76
|
+
* ledger lock provide this; memory refuses overlapping transactions.
|
|
77
|
+
*/
|
|
72
78
|
appendRevision(record: Omit<SettingRevisionRecord, "seq">): Promise<number>;
|
|
73
79
|
listRevisions(required: {
|
|
74
80
|
settingId: UUID;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { adaptLegacyAuthorize } from "../tool-permissions.js";
|
|
2
2
|
import { agentWriteRules, buildSettingsValueWriteHandler, toWriteDeps } from "./agent-value-write-tools.js";
|
|
3
3
|
import { findAgentWriteRule } from "./agent-write-denylist.js";
|
|
4
|
-
import { buildDomainRegistrations, indexCatalogById, optionalString, requireInputRecord,
|
|
4
|
+
import { buildDomainRegistrations, indexCatalogById, optionalString, requireInputRecord, requireString, withSchemaOnRejection } from "../registration-kit.js";
|
|
5
5
|
import { ToolInputError } from "../tool-registry.js";
|
|
6
6
|
import { requireToolPermission } from "../tool-permissions.js";
|
|
7
7
|
import { getSettingsAgentToolCatalog } from "./agent-tools.js";
|
|
@@ -140,15 +140,21 @@ export function buildSettingsRegistrations(routeDeps) {
|
|
|
140
140
|
settings_set_value: buildSettingsValueWriteHandler(routeDeps, "settings_set_value"),
|
|
141
141
|
settings_clear_value: buildSettingsValueWriteHandler(routeDeps, "settings_clear_value"),
|
|
142
142
|
settings_list_definitions: async (ctx) => {
|
|
143
|
-
|
|
143
|
+
const input = requireInputRecord({ input: ctx.input === undefined ? {} : ctx.input });
|
|
144
|
+
const search = optionalString({ input, key: "search" })?.toLowerCase();
|
|
145
|
+
const namespace = optionalString({ input, key: "namespace" });
|
|
144
146
|
await routeDeps.settingsReady;
|
|
145
147
|
await requireToolPermission({ authorize: adaptLegacyAuthorize({ authorize: routeDeps.authorize }), workspaceId: routeDeps.workspaceId, principalId: ctx.principal.id, permission: "settings.read.definitions" }, { entityType: "setting-definition" });
|
|
146
148
|
const [platformDefs, siteDefs] = await Promise.all([
|
|
147
149
|
routeDeps.settingsRepo.listActiveDefinitions({ workspaceId: null }),
|
|
148
150
|
routeDeps.settingsRepo.listActiveDefinitions({ workspaceId: routeDeps.workspaceId }),
|
|
149
151
|
]);
|
|
152
|
+
// Filter only authorized workspace rows, before alias lookups; never search stored values.
|
|
153
|
+
const definitions = [...platformDefs, ...siteDefs]
|
|
154
|
+
.filter((def) => namespace === undefined || def.namespace === namespace)
|
|
155
|
+
.filter((def) => !search || `${def.namespace}.${def.key}`.toLowerCase().includes(search));
|
|
150
156
|
const rules = agentWriteRules(routeDeps);
|
|
151
|
-
const data = (await Promise.all(
|
|
157
|
+
const data = (await Promise.all(definitions.map((def) => describeDefinitionForAgent(routeDeps, rules, def))))
|
|
152
158
|
.sort((a, b) => a.namespace.localeCompare(b.namespace) || a.key.localeCompare(b.key));
|
|
153
159
|
return { data };
|
|
154
160
|
},
|
|
@@ -110,8 +110,21 @@ export interface SetValueRequired {
|
|
|
110
110
|
requiredPermissionOverride?: string | undefined;
|
|
111
111
|
};
|
|
112
112
|
}
|
|
113
|
-
/**
|
|
114
|
-
export
|
|
113
|
+
/** Optional concurrency guard shared by `set` and `clear`. */
|
|
114
|
+
export interface SettingsValueWriteOptions {
|
|
115
|
+
/** Compare the addressed layer's stored seq, including cleared rows; 0 means no stored row.
|
|
116
|
+
* Omission preserves unconditional writes. Defaults, inherited values and definition revisions
|
|
117
|
+
* do not count. A mismatch throws SettingsRevisionConflictError and rolls back the write.
|
|
118
|
+
*/
|
|
119
|
+
readonly expectedRevisionSeq?: number | undefined;
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Authorize -> validate -> value + revision in one transaction. An optional expectedRevisionSeq
|
|
123
|
+
* refuses a stale scoped value and rolls back its revision; omission keeps unconditional writes.
|
|
124
|
+
* @example await set({ deps, input }, { expectedRevisionSeq: snapshotSeq });
|
|
125
|
+
* See docs/decisions/DR-003-settings-ledger-invariants.md.
|
|
126
|
+
*/
|
|
127
|
+
export declare function set(required: SetValueRequired, optional?: SettingsValueWriteOptions): Promise<{
|
|
115
128
|
value: JsonValue;
|
|
116
129
|
revisionSeq: number;
|
|
117
130
|
}>;
|
|
@@ -135,8 +148,14 @@ export interface ClearValueRequired {
|
|
|
135
148
|
authWorkspaceId?: UUID | undefined;
|
|
136
149
|
};
|
|
137
150
|
}
|
|
138
|
-
/**
|
|
139
|
-
|
|
151
|
+
/**
|
|
152
|
+
* Authorize -> cleared value + revision in one transaction; the optional concurrency guard has
|
|
153
|
+
* the same semantics as `set`. With skipTransaction, the enclosing caller must propagate errors
|
|
154
|
+
* so its transaction rolls back the appended revision on conflict.
|
|
155
|
+
* @example await clear({ deps, input }, { expectedRevisionSeq: snapshotSeq });
|
|
156
|
+
* See docs/decisions/DR-003-settings-ledger-invariants.md.
|
|
157
|
+
*/
|
|
158
|
+
export declare function clear(required: ClearValueRequired, optional?: SettingsValueWriteOptions): Promise<{
|
|
140
159
|
revisionSeq: number;
|
|
141
160
|
}>;
|
|
142
161
|
export interface ResetNamespaceRequired {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { nowIso as kernelNowIso } from "../primitives/index.js";
|
|
2
|
-
import { AliasDepthExceededError, DefinitionInvalidError, DefinitionNotFoundError, DefinitionTombstonedError, ForbiddenError, PrincipalNotFoundError, RenameRetypeConflictError, ScopeNotAllowedError, ValueValidationFailedError, } from "./errors.js";
|
|
2
|
+
import { AliasDepthExceededError, DefinitionInvalidError, DefinitionNotFoundError, DefinitionTombstonedError, ForbiddenError, PrincipalNotFoundError, RenameRetypeConflictError, ScopeNotAllowedError, SettingsRevisionConflictError, ValueValidationFailedError, } from "./errors.js";
|
|
3
3
|
import { invalidateDefinitionNamespaceCache, resolveDefinitionRaw, validateDefinitionInput, validateValueAgainstSchema, } from "./settings.js";
|
|
4
4
|
import { SCOPE_BIT } from "./types.js";
|
|
5
5
|
/**
|
|
@@ -148,8 +148,33 @@ async function assertTargetPrincipalInWorkspace(deps, input) {
|
|
|
148
148
|
throw new PrincipalNotFoundError(`principal '${input.principalId}' was not found in workspace '${input.workspaceId}'`, input.principalId, input.workspaceId);
|
|
149
149
|
}
|
|
150
150
|
}
|
|
151
|
-
/**
|
|
152
|
-
|
|
151
|
+
/**
|
|
152
|
+
* Check the addressed value after appendRevision has taken the ledger lock. On Postgres, a read
|
|
153
|
+
* before that lock can see the same old seq in two READ COMMITTED transactions and lose an update.
|
|
154
|
+
* The caller's enclosing transaction rolls the appended revision back on conflict.
|
|
155
|
+
* @complexity At most one scoped value read; no reads when the option is omitted.
|
|
156
|
+
*/
|
|
157
|
+
async function assertExpectedRevision(required, optional = {}) {
|
|
158
|
+
if (optional.expectedRevisionSeq === undefined)
|
|
159
|
+
return;
|
|
160
|
+
const { repo, settingId, input } = required;
|
|
161
|
+
const current = input.scope === "global"
|
|
162
|
+
? await repo.getGlobalValue(settingId)
|
|
163
|
+
: input.scope === "workspace"
|
|
164
|
+
? await repo.getWorkspaceValue({ workspaceId: input.workspaceId, settingId })
|
|
165
|
+
: await repo.getUserValue({ workspaceId: input.workspaceId, principalId: input.principalId ?? input.callerPrincipalId, settingId });
|
|
166
|
+
const actualRevisionSeq = current?.seq ?? 0;
|
|
167
|
+
if (actualRevisionSeq !== optional.expectedRevisionSeq) {
|
|
168
|
+
throw new SettingsRevisionConflictError({ expectedRevisionSeq: optional.expectedRevisionSeq, actualRevisionSeq });
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Authorize -> validate -> value + revision in one transaction. An optional expectedRevisionSeq
|
|
173
|
+
* refuses a stale scoped value and rolls back its revision; omission keeps unconditional writes.
|
|
174
|
+
* @example await set({ deps, input }, { expectedRevisionSeq: snapshotSeq });
|
|
175
|
+
* See docs/decisions/DR-003-settings-ledger-invariants.md.
|
|
176
|
+
*/
|
|
177
|
+
export async function set(required, optional = {}) {
|
|
153
178
|
const { deps, input } = required;
|
|
154
179
|
const permission = input.requiredPermissionOverride ??
|
|
155
180
|
deriveRequiredPermission({
|
|
@@ -190,6 +215,7 @@ export async function set(required) {
|
|
|
190
215
|
changeSetId: null,
|
|
191
216
|
createdAt: now,
|
|
192
217
|
});
|
|
218
|
+
await assertExpectedRevision({ repo: deps.repo, settingId: definition.settingId, input }, optional);
|
|
193
219
|
const base = {
|
|
194
220
|
settingId: definition.settingId,
|
|
195
221
|
scope: input.scope,
|
|
@@ -222,8 +248,14 @@ export async function set(required) {
|
|
|
222
248
|
});
|
|
223
249
|
return result;
|
|
224
250
|
}
|
|
225
|
-
/**
|
|
226
|
-
|
|
251
|
+
/**
|
|
252
|
+
* Authorize -> cleared value + revision in one transaction; the optional concurrency guard has
|
|
253
|
+
* the same semantics as `set`. With skipTransaction, the enclosing caller must propagate errors
|
|
254
|
+
* so its transaction rolls back the appended revision on conflict.
|
|
255
|
+
* @example await clear({ deps, input }, { expectedRevisionSeq: snapshotSeq });
|
|
256
|
+
* See docs/decisions/DR-003-settings-ledger-invariants.md.
|
|
257
|
+
*/
|
|
258
|
+
export async function clear(required, optional = {}) {
|
|
227
259
|
const { deps, input } = required;
|
|
228
260
|
if (!input.skipAuthorize) {
|
|
229
261
|
const permission = deriveRequiredPermission({
|
|
@@ -262,6 +294,7 @@ export async function clear(required) {
|
|
|
262
294
|
changeSetId: null,
|
|
263
295
|
createdAt: now,
|
|
264
296
|
});
|
|
297
|
+
await assertExpectedRevision({ repo: deps.repo, settingId: definition.settingId, input }, optional);
|
|
265
298
|
const base = {
|
|
266
299
|
settingId: definition.settingId,
|
|
267
300
|
scope: input.scope,
|
|
@@ -8,8 +8,8 @@ export interface NormalizedToolInput {
|
|
|
8
8
|
export type ToolInputNormalizer = (required: {
|
|
9
9
|
input: Readonly<Record<string, unknown>>;
|
|
10
10
|
}, optional?: Record<string, never>) => NormalizedToolInput | Promise<NormalizedToolInput>;
|
|
11
|
-
/** Canonicalize one batch layer before merging;
|
|
12
|
-
* final normalization. Throws ToolInputError for collisions within this layer, never across
|
|
11
|
+
/** Canonicalize one batch layer (including declared aliases) before merging; leave value conversion
|
|
12
|
+
* and domain hooks for final normalization. Throws ToolInputError for collisions within this layer, never across
|
|
13
13
|
* layers. Uses the same bounded schema walk as normalizeToolInput.
|
|
14
14
|
* @example canonicalizeToolInputKeys({ input: { max_chars: '1000' }, schema });
|
|
15
15
|
*/
|
|
@@ -20,7 +20,8 @@ export declare function canonicalizeToolInputKeys({ input, schema }: {
|
|
|
20
20
|
input: Record<string, unknown>;
|
|
21
21
|
notes: readonly string[];
|
|
22
22
|
};
|
|
23
|
-
/** Normalize before policy, confirmation and handler validation.
|
|
23
|
+
/** Normalize before policy, confirmation and handler validation. Schema properties may declare
|
|
24
|
+
* `aliases: ['search', 'query']`; domain hooks own transformations beyond declared key aliases.
|
|
24
25
|
* Never changes the caller's object or includes values in notes. Invalid/ambiguous input remains
|
|
25
26
|
* a ToolInputError; ordinary domain/schema validation still belongs to the registered handler.
|
|
26
27
|
* @complexity Two bounded schema walks plus the optional domain hook's cost.
|
|
@@ -15,11 +15,16 @@ function sameLengthNearMiss({ left, right, index }) {
|
|
|
15
15
|
return left.slice(index + 1) === right.slice(index + 1)
|
|
16
16
|
|| (left[index] === right[index + 1] && left[index + 1] === right[index] && left.slice(index + 2) === right.slice(index + 2));
|
|
17
17
|
}
|
|
18
|
-
/**
|
|
18
|
+
/** Scope selectors name a container, never the resource: guessing `workspaceId` as `id` (or the
|
|
19
|
+
* reverse) would silently drop or invent a scope. Only a declared name or alias may accept one. */
|
|
20
|
+
const SCOPE_SELECTORS = new Set(['workspaceid', 'siteid']);
|
|
21
|
+
/** Whether either key is the generic id shorthand for the other's id suffix; never for a scope selector. */
|
|
19
22
|
function isIdShorthand({ left, right }, _options = {}) {
|
|
23
|
+
if (SCOPE_SELECTORS.has(left) || SCOPE_SELECTORS.has(right))
|
|
24
|
+
return false;
|
|
20
25
|
return (left === 'id' && right.endsWith('id')) || (right === 'id' && left.endsWith('id'));
|
|
21
26
|
}
|
|
22
|
-
/** Detect id shorthand or one insertion, deletion, substitution or transposition
|
|
27
|
+
/** Detect id shorthand or one insertion, deletion, substitution or transposition. */
|
|
23
28
|
function nearMiss({ left, right }, _options = {}) {
|
|
24
29
|
if (left.length > 128 || right.length > 128)
|
|
25
30
|
return false;
|
|
@@ -53,6 +58,19 @@ function coerceString({ value, types, path, notes }) {
|
|
|
53
58
|
}
|
|
54
59
|
return coerceNumber({ value, types, path, notes });
|
|
55
60
|
}
|
|
61
|
+
/** Unwrap only a singleton string array; refuse malformed arrays without exposing values.
|
|
62
|
+
* Keys-only batch layers preserve values until the final merged input is normalized.
|
|
63
|
+
* @complexity O(t) time for t allowed schema types, O(1) space; array contents are not scanned.
|
|
64
|
+
*/
|
|
65
|
+
function unwrapStringArray({ value, types, step }) {
|
|
66
|
+
if (step.walk.keysOnly || !Array.isArray(value) || !types.includes('string'))
|
|
67
|
+
return value;
|
|
68
|
+
if (value.length !== 1 || typeof value[0] !== 'string') {
|
|
69
|
+
throw new ToolInputError({ message: `${step.path}: send one value as a string or a one-element string array.` });
|
|
70
|
+
}
|
|
71
|
+
step.walk.notes.push(`${step.path}: one-item string array→string`);
|
|
72
|
+
return value[0];
|
|
73
|
+
}
|
|
56
74
|
function normalizeArray({ value, schema, step }) {
|
|
57
75
|
const { path, depth, walk } = step;
|
|
58
76
|
let list = value;
|
|
@@ -65,12 +83,30 @@ function normalizeArray({ value, schema, step }) {
|
|
|
65
83
|
return list;
|
|
66
84
|
return list.map((item, index) => normalizeValue({ value: item, schema: schema.items, step: { path: `${path}[${index}]`, depth: depth + 1, walk } }));
|
|
67
85
|
}
|
|
68
|
-
/**
|
|
86
|
+
/** Match a canonical spelling or its property's declared string aliases, ignoring case/separators.
|
|
87
|
+
* Malformed alias annotations confer no alternate names; exact property names still win.
|
|
88
|
+
* @complexity O(a) comparisons for a aliases on this property; no input mutation or effects.
|
|
89
|
+
*/
|
|
90
|
+
function matchesDeclaredKey({ key, name, schema }, _options = {}) {
|
|
91
|
+
if (keyShape(name) === keyShape(key))
|
|
92
|
+
return true;
|
|
93
|
+
if (!object(schema) || !Array.isArray(schema.aliases))
|
|
94
|
+
return false;
|
|
95
|
+
return schema.aliases.some(alias => typeof alias === 'string' && keyShape(alias) === keyShape(key));
|
|
96
|
+
}
|
|
97
|
+
/** Exact names, then case/separator variants and declared aliases, then a sole hint; ambiguous hints stay unresolved
|
|
98
|
+
* for the domain hook or closed-schema refusal. Never choose one of several candidates.
|
|
99
|
+
* Hints apply only where a closed schema would refuse the key: an open schema accepts it as data.
|
|
100
|
+
* @complexity O(p + a) comparisons for p properties and a declared aliases; O(p) space.
|
|
101
|
+
*/
|
|
69
102
|
function canonicalKey({ key, location, shape }) {
|
|
70
|
-
const candidates = Object.hasOwn(shape.properties, key) ? [key] : shape.names.filter(name =>
|
|
103
|
+
const candidates = Object.hasOwn(shape.properties, key) ? [key] : shape.names.filter(name => matchesDeclaredKey({ key, name, schema: shape.properties[name] }));
|
|
71
104
|
if (candidates.length > 1)
|
|
72
105
|
throw new ToolInputError({ message: `Ambiguous input '${location}': matches ${candidates.join(', ')}. Use exactly one canonical property name.` });
|
|
73
|
-
|
|
106
|
+
if (candidates.length === 1 || shape.schema.additionalProperties !== false)
|
|
107
|
+
return candidates[0];
|
|
108
|
+
const hints = shape.names.filter(name => nearMiss({ left: keyShape(key), right: keyShape(name) }));
|
|
109
|
+
return hints.length === 1 ? hints[0] : undefined;
|
|
74
110
|
}
|
|
75
111
|
/** Describe the local schema and likely names without choosing a name or exposing input values.
|
|
76
112
|
* Target and permission fields must be declared in the tool schema before callers can use them.
|
|
@@ -111,7 +147,7 @@ function normalizeObject({ value, schema, step }) {
|
|
|
111
147
|
return Object.fromEntries(Object.entries(value).map(([key, raw]) => normalizeEntry({ key, raw, shape, sources, step })));
|
|
112
148
|
}
|
|
113
149
|
/** Schema-guided copies only: unconstrained data and unsupported schema dialects stay untouched.
|
|
114
|
-
* Cost is O(k*p) per object with k supplied keys and
|
|
150
|
+
* Cost is O(k*(p+a)) comparisons per object with k supplied keys, p properties and a aliases; recursion is bounded
|
|
115
151
|
* to 64 levels so a hostile input/schema cannot exhaust the execution gate's stack.
|
|
116
152
|
*/
|
|
117
153
|
function normalizeValue({ value, schema, step }) {
|
|
@@ -126,14 +162,15 @@ function normalizeValue({ value, schema, step }) {
|
|
|
126
162
|
}
|
|
127
163
|
if (types.includes('array'))
|
|
128
164
|
return normalizeArray({ value, schema, step });
|
|
129
|
-
|
|
165
|
+
const unwrapped = unwrapStringArray({ value, types, step });
|
|
166
|
+
return object(unwrapped) ? normalizeObject({ value: unwrapped, schema, step }) : unwrapped;
|
|
130
167
|
}
|
|
131
168
|
/** A walk from the root of one input. */
|
|
132
169
|
function walkRoot({ input, schema, notes, validateClosed, keysOnly }) {
|
|
133
170
|
return normalizeValue({ value: input, schema, step: { path: '', depth: 0, walk: { notes, validateClosed, keysOnly } } });
|
|
134
171
|
}
|
|
135
|
-
/** Canonicalize one batch layer before merging;
|
|
136
|
-
* final normalization. Throws ToolInputError for collisions within this layer, never across
|
|
172
|
+
/** Canonicalize one batch layer (including declared aliases) before merging; leave value conversion
|
|
173
|
+
* and domain hooks for final normalization. Throws ToolInputError for collisions within this layer, never across
|
|
137
174
|
* layers. Uses the same bounded schema walk as normalizeToolInput.
|
|
138
175
|
* @example canonicalizeToolInputKeys({ input: { max_chars: '1000' }, schema });
|
|
139
176
|
*/
|
|
@@ -141,7 +178,8 @@ export function canonicalizeToolInputKeys({ input, schema }, _options = {}) {
|
|
|
141
178
|
const notes = [];
|
|
142
179
|
return { input: walkRoot({ input, schema, notes, validateClosed: false, keysOnly: true }), notes };
|
|
143
180
|
}
|
|
144
|
-
/** Normalize before policy, confirmation and handler validation.
|
|
181
|
+
/** Normalize before policy, confirmation and handler validation. Schema properties may declare
|
|
182
|
+
* `aliases: ['search', 'query']`; domain hooks own transformations beyond declared key aliases.
|
|
145
183
|
* Never changes the caller's object or includes values in notes. Invalid/ambiguous input remains
|
|
146
184
|
* a ToolInputError; ordinary domain/schema validation still belongs to the registered handler.
|
|
147
185
|
* @complexity Two bounded schema walks plus the optional domain hook's cost.
|
package/dist/tool-registry.d.ts
CHANGED
|
@@ -46,6 +46,9 @@ export interface ToolDescriptor {
|
|
|
46
46
|
* JSON-Schema-shaped description of this tool's accepted input, carried as `unknown` because the
|
|
47
47
|
* gate reads common property/type fields for input normalization but leaves validation and
|
|
48
48
|
* schema dialects to the handler; no validator dependency belongs in every registry consumer.
|
|
49
|
+
* Each property may declare `aliases: readonly string[]` (e.g. contains: { type: 'string',
|
|
50
|
+
* aliases: ['search', 'query'] }). The gate treats these like case variants, rejects ambiguous
|
|
51
|
+
* or conflicting names, and reports the rename in normalization notes.
|
|
49
52
|
*
|
|
50
53
|
* It exists because *every* discovery mechanism needs it and nothing else in the kernel can
|
|
51
54
|
* supply it: a caller that can enumerate `ToolRegistry.list({})` still cannot tell an agent what
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jini-ai/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.11",
|
|
4
4
|
"description": "The Jini kernel: typed DI tokens, pack composition and lifecycle, the tool registry, redaction, and origin/API-token auth primitives. Zero dependencies.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"repository": {
|