@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 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
- export const LABELED_SECRET_RE = /\b(authorization|bearer|api[-_]?key|access[-_]?token|refresh[-_]?token|client[-_]?secret|password|secret|cookie)\b\s*[:=]\s*\S+/gi;
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
- * No arguments — `settings_list_definitions` takes none; it always enumerates the caller's own
53
- * (boot-wired) workspace's site-owned partition plus the shared platform partition. */
54
- const NO_INPUT_SCHEMA = {
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 every active setting definition visible to this workspace (platform core/theme definitions plus this workspace's own site-owned definitions): namespace, key, owner kind, scope bitmask, status, version, schema, agentWritable and confirmationRequired. Use before writing a setting. Metadata only — never a value.",
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: NO_INPUT_SCHEMA,
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 {
@@ -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 {
@@ -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";
@@ -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";
@@ -68,7 +68,13 @@ export interface SettingsRepoPort {
68
68
  principalId: UUID;
69
69
  settingId: UUID;
70
70
  }): Promise<void>;
71
- /** Appends a revision and returns its assigned `seq` (used to stamp the paired value row). */
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, requireNoInput, requireString, withSchemaOnRejection } from "../registration-kit.js";
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
- requireNoInput({ input: ctx.input });
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([...platformDefs, ...siteDefs].map((def) => describeDefinitionForAgent(routeDeps, rules, def))))
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
- /** chokepoint write: authorize -> validate -> value + revision, same tx. See docs/decisions/DR-003-settings-ledger-invariants.md. */
114
- export declare function set(required: SetValueRequired): Promise<{
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
- /** chokepoint write: authorize -> value + revision (state='cleared'), same tx. See docs/decisions/DR-003-settings-ledger-invariants.md. */
139
- export declare function clear(required: ClearValueRequired): Promise<{
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
- /** chokepoint write: authorize -> validate -> value + revision, same tx. See docs/decisions/DR-003-settings-ledger-invariants.md. */
152
- export async function set(required) {
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
- /** chokepoint write: authorize -> value + revision (state='cleared'), same tx. See docs/decisions/DR-003-settings-ledger-invariants.md. */
226
- export async function clear(required) {
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; preserve values and domain aliases for the
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. Domain hooks own semantic aliases.
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
- /** Whether either key is the generic id shorthand for the other's id suffix; hints only. */
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, for hints only. */
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
- /** The declared property a supplied key names, or undefined; refuses a key that names two. */
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 => keyShape(name) === keyShape(key));
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
- return candidates[0];
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 p schema properties; recursion is bounded
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
- return object(value) ? normalizeObject({ value, schema, step }) : value;
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; preserve values and domain aliases for the
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. Domain hooks own semantic aliases.
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.
@@ -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.4.9",
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": {