@jini-ai/core 0.4.8 → 0.6.10

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.
Files changed (77) hide show
  1. package/CHANGELOG.md +23 -2
  2. package/README.md +5 -0
  3. package/dist/agent-tools.d.ts +1 -1
  4. package/dist/approval.d.ts +15 -0
  5. package/dist/approval.js +87 -22
  6. package/dist/batch-tool-inputs.d.ts +147 -0
  7. package/dist/batch-tool-inputs.js +257 -0
  8. package/dist/feature-flags.d.ts +21 -0
  9. package/dist/feature-flags.js +17 -0
  10. package/dist/fetch/index.d.ts +2 -1
  11. package/dist/fetch/index.js +1 -0
  12. package/dist/fetch/loader-read.d.ts +16 -0
  13. package/dist/fetch/loader-read.js +22 -0
  14. package/dist/fetch/types.d.ts +12 -2
  15. package/dist/gated-mutations/gateway.js +32 -27
  16. package/dist/index.d.ts +8 -1
  17. package/dist/index.js +7 -1
  18. package/dist/object-store.d.ts +115 -0
  19. package/dist/object-store.js +70 -0
  20. package/dist/origin-validation.js +64 -57
  21. package/dist/primitives/http.d.ts +2 -2
  22. package/dist/primitives/index.d.ts +1 -0
  23. package/dist/primitives/index.js +1 -0
  24. package/dist/primitives/paths.js +17 -10
  25. package/dist/primitives/tasks.d.ts +10 -0
  26. package/dist/primitives/tasks.js +8 -0
  27. package/dist/provider-catalog.d.ts +76 -0
  28. package/dist/provider-catalog.js +6 -0
  29. package/dist/provider-registry.d.ts +144 -0
  30. package/dist/provider-registry.js +138 -0
  31. package/dist/records.d.ts +24 -0
  32. package/dist/records.js +25 -0
  33. package/dist/redact.d.ts +32 -0
  34. package/dist/redact.js +91 -34
  35. package/dist/registration-kit.d.ts +7 -4
  36. package/dist/registration-kit.js +81 -62
  37. package/dist/settings/agent-value-write-tools.d.ts +19 -0
  38. package/dist/settings/agent-value-write-tools.js +131 -70
  39. package/dist/settings/ensure-definitions.d.ts +4 -2
  40. package/dist/settings/ensure-definitions.js +79 -63
  41. package/dist/settings/errors.d.ts +13 -0
  42. package/dist/settings/errors.js +15 -0
  43. package/dist/settings/express/cms-adapter.d.ts +2 -2
  44. package/dist/settings/express/cms-adapter.js +34 -22
  45. package/dist/settings/express/http-request.d.ts +33 -0
  46. package/dist/settings/express/http-request.js +49 -0
  47. package/dist/settings/express/http.d.ts +2 -2
  48. package/dist/settings/express/http.js +16 -39
  49. package/dist/settings/express/stream.d.ts +2 -2
  50. package/dist/settings/express/stream.js +49 -27
  51. package/dist/settings/index.d.ts +2 -2
  52. package/dist/settings/index.js +1 -1
  53. package/dist/settings/ports.d.ts +7 -1
  54. package/dist/settings/repo.memory.js +7 -4
  55. package/dist/settings/settings.js +54 -60
  56. package/dist/settings/tool-registrations.d.ts +2 -3
  57. package/dist/settings/tool-registrations.js +64 -39
  58. package/dist/settings/write-service.d.ts +42 -4
  59. package/dist/settings/write-service.js +72 -5
  60. package/dist/target-scope.d.ts +2 -0
  61. package/dist/target-scope.js +18 -2
  62. package/dist/text/escape.js +2 -2
  63. package/dist/text/sanitize-untrusted-text.d.ts +5 -0
  64. package/dist/text/sanitize-untrusted-text.js +55 -35
  65. package/dist/tool-exposure.d.ts +79 -0
  66. package/dist/tool-exposure.js +163 -0
  67. package/dist/tool-input-errors.d.ts +23 -0
  68. package/dist/tool-input-errors.js +24 -0
  69. package/dist/tool-input-normalization.d.ts +37 -0
  70. package/dist/tool-input-normalization.js +188 -0
  71. package/dist/tool-metadata-types.d.ts +26 -0
  72. package/dist/tool-metadata-types.js +2 -0
  73. package/dist/tool-metadata.d.ts +2 -23
  74. package/dist/tool-registry.d.ts +38 -22
  75. package/dist/tool-registry.js +35 -20
  76. package/package.json +20 -2
  77. package/plugin-directory.js +72 -33
@@ -1,51 +1,65 @@
1
- import { adaptLegacyAuthorize } from "../tool-permissions.js";
1
+ import { adaptLegacyAuthorize, ForbiddenError as PermissionDeniedError, requireToolPermission } from "../tool-permissions.js";
2
2
  import { ToolInputError } from "../tool-registry.js";
3
- import { ForbiddenError as PermissionDeniedError } from "../tool-permissions.js";
4
- import { requireToolPermission } from "../tool-permissions.js";
5
3
  import { AGENT_WRITE_CONFIRMATION_SETTINGS, AGENT_WRITE_DENIED_SETTINGS, findAgentWriteRule } from "./agent-write-denylist.js";
6
4
  import { DefinitionNotFoundError, DefinitionTombstonedError, ForbiddenError, ScopeNotAllowedError, ValueValidationFailedError } from "./errors.js";
7
5
  import { getEffective, resolveDefinitionRaw, validateValueAgainstSchema } from "./settings.js";
8
6
  import { SCOPE_BIT } from "./types.js";
9
7
  import { clear, deriveRequiredPermission, set } from "./write-service.js";
8
+ const WRITE_FIELDS = {
9
+ settings_set_value: ["namespace", "key", "value", "scope"],
10
+ settings_clear_value: ["namespace", "key", "scope"],
11
+ };
10
12
  /** Strict boundary, including direct handler calls. JSON is cloned before the first await
11
- * so a held-open card cannot consent to a value later changed by the caller. O(input size). */
13
+ * so a held-open card cannot consent to a value later changed by the caller. O(input size).
14
+ * Refusal order: non-object, unexpected field, namespace, key, scope, missing value, non-JSON value. */
12
15
  function parseTarget(toolId, raw) {
13
- if (!raw || typeof raw !== "object" || Array.isArray(raw))
14
- throw new ToolInputError({ message: `${toolId}: input must be an object containing namespace and key.` });
15
- const input = raw;
16
- const fields = toolId === "settings_set_value" ? ["namespace", "key", "value", "scope"] : ["namespace", "key", "scope"];
16
+ const input = requireInputObject(toolId, raw);
17
+ const fields = WRITE_FIELDS[toolId];
17
18
  const extra = Object.keys(input).find((key) => !fields.includes(key));
18
19
  if (extra)
19
20
  throw new ToolInputError({ message: `${toolId}: unexpected input '${extra}'. Only ${fields.join(", ")} are accepted; the caller's own user layer is the only user target.` });
20
- for (const field of ["namespace", "key"]) {
21
- if (typeof input[field] !== "string" || !input[field].trim())
22
- throw new ToolInputError({ message: `${toolId}: '${field}' must be a non-empty string. Call settings_list_definitions to see valid keys.` });
23
- }
24
- const namespace = input.namespace;
25
- const key = input.key;
26
- const scope = input.scope === undefined ? "workspace" : input.scope;
27
- if (scope !== "workspace" && scope !== "user")
28
- throw new ToolInputError({ message: `${toolId}: scope must be 'workspace' or 'user'; global settings are not writable by this tool.` });
21
+ const namespace = requireCoordinate(toolId, input, "namespace");
22
+ const key = requireCoordinate(toolId, input, "key");
23
+ const scope = parseScope(toolId, input.scope);
29
24
  if (toolId === "settings_clear_value")
30
25
  return { namespace, key, scope };
26
+ return { namespace, key, scope, value: snapshotJsonValue(toolId, input) };
27
+ }
28
+ function requireInputObject(toolId, raw) {
29
+ if (!raw || typeof raw !== "object" || Array.isArray(raw))
30
+ throw new ToolInputError({ message: `${toolId}: input must be an object containing namespace and key.` });
31
+ return raw;
32
+ }
33
+ function requireCoordinate(toolId, input, field) {
34
+ const value = input[field];
35
+ if (typeof value !== "string" || !value.trim())
36
+ throw new ToolInputError({ message: `${toolId}: '${field}' must be a non-empty string. Call settings_list_definitions to see valid keys.` });
37
+ return value;
38
+ }
39
+ /** Omitted means workspace; anything other than workspace/user (including null and global) is refused. */
40
+ function parseScope(toolId, raw) {
41
+ const scope = raw === undefined ? "workspace" : raw;
42
+ if (scope !== "workspace" && scope !== "user")
43
+ throw new ToolInputError({ message: `${toolId}: scope must be 'workspace' or 'user'; global settings are not writable by this tool.` });
44
+ return scope;
45
+ }
46
+ function snapshotJsonValue(toolId, input) {
31
47
  if (!("value" in input))
32
48
  throw new ToolInputError({ message: `${toolId}: a JSON 'value' is required.` });
33
49
  // The executor validates JSON input too. The round trip protects direct callers and isolates
34
50
  // the snapshot; values JSON would silently change (NaN, undefined, functions) are rejected.
35
- let serialized;
51
+ // A serialization that yields nothing makes JSON.parse throw, so it is refused the same way.
36
52
  try {
37
- serialized = JSON.stringify(input.value, (_key, value) => {
38
- if (value === undefined || typeof value === "function" || typeof value === "symbol" || (typeof value === "number" && !Number.isFinite(value)))
39
- throw new Error("not JSON");
40
- return value;
41
- });
53
+ return JSON.parse(JSON.stringify(input.value, rejectNonJson));
42
54
  }
43
- catch {
44
- throw new ToolInputError({ message: `${toolId}: value must be valid JSON.` });
55
+ catch (cause) {
56
+ throw new ToolInputError({ message: `${toolId}: value must be valid JSON.` }, { cause });
45
57
  }
46
- if (serialized === undefined)
47
- throw new ToolInputError({ message: `${toolId}: value must be valid JSON.` });
48
- return { namespace, key, scope, value: JSON.parse(serialized) };
58
+ }
59
+ function rejectNonJson(_key, value) {
60
+ if (value === undefined || typeof value === "function" || typeof value === "symbol" || (typeof value === "number" && !Number.isFinite(value)))
61
+ throw new Error("not JSON");
62
+ return value;
49
63
  }
50
64
  /** Definition/scope/value preflight for readable refusal before any card. O(1) repository
51
65
  * lookups plus alias resolution and schema validation, with no writes. */
@@ -68,17 +82,24 @@ function readLayer(deps, target, settingId, principalId) {
68
82
  const base = { workspaceId: deps.workspaceId, settingId };
69
83
  return target.scope === "user" ? deps.settingsRepo.getUserValue({ ...base, principalId }) : deps.settingsRepo.getWorkspaceValue(base);
70
84
  }
85
+ /** The effective agent-write key policy: package denials plus host extensions (never agent input),
86
+ * and the host's confirmation policy (replacing the package defaults) plus host extensions. O(r). */
87
+ export function agentWriteRules(deps) {
88
+ return {
89
+ denied: [...AGENT_WRITE_DENIED_SETTINGS, ...(deps.extraDeniedSettings ?? [])],
90
+ confirmation: [...(deps.confirmationSettings ?? AGENT_WRITE_CONFIRMATION_SETTINGS), ...(deps.extraConfirmationSettings ?? [])],
91
+ };
92
+ }
71
93
  /** Fail closed on host-denied keys, including a protected target reached through an alias.
72
94
  * Returns a confirmation rule if either coordinate needs one; O(r) in fixed policy rules. */
73
95
  function writePolicy(deps, toolId, target, definition) {
74
96
  const targets = definition ? [target, definition] : [target];
75
- const denied = [...AGENT_WRITE_DENIED_SETTINGS, ...(deps.extraDeniedSettings ?? [])];
97
+ const { denied, confirmation } = agentWriteRules(deps);
76
98
  for (const coordinate of targets) {
77
99
  const rule = findAgentWriteRule(denied, coordinate);
78
100
  if (rule)
79
101
  throw new ToolInputError({ message: `${toolId}: '${coordinate.namespace}.${coordinate.key}' can't be changed by the assistant: ${rule.reason}. The owner can change it in Settings.` });
80
102
  }
81
- const confirmation = [...(deps.confirmationSettings ?? AGENT_WRITE_CONFIRMATION_SETTINGS), ...(deps.extraConfirmationSettings ?? [])];
82
103
  for (const coordinate of targets) {
83
104
  const rule = findAgentWriteRule(confirmation, coordinate);
84
105
  if (rule)
@@ -89,15 +110,84 @@ function writePolicy(deps, toolId, target, definition) {
89
110
  /** Maps expected caller-fixable errors; infrastructure failures remain untouched. O(1). */
90
111
  function rethrowWriteError(error, toolId, target, permission) {
91
112
  if (error instanceof DefinitionNotFoundError || error instanceof DefinitionTombstonedError)
92
- throw new ToolInputError({ message: `${toolId}: ${error.message}. Call settings_list_definitions to see valid keys.` });
113
+ throw new ToolInputError({ message: `${toolId}: ${error.message}. Call settings_list_definitions to see valid keys.` }, { cause: error });
93
114
  if (error instanceof ScopeNotAllowedError)
94
- throw new ToolInputError({ message: `${toolId}: '${target.namespace}.${target.key}' can't be set ${target.scope === "user" ? "per-user" : "at workspace scope"}. Call settings_list_definitions to see allowed scopes.` });
115
+ throw new ToolInputError({ message: `${toolId}: '${target.namespace}.${target.key}' can't be set ${target.scope === "user" ? "per-user" : "at workspace scope"}. Call settings_list_definitions to see allowed scopes.` }, { cause: error });
95
116
  if (error instanceof ValueValidationFailedError)
96
- throw new ToolInputError({ message: `${toolId}: ${error.message}. Call settings_list_definitions and use a value matching the setting's schema.` });
117
+ throw new ToolInputError({ message: `${toolId}: ${error.message}. Call settings_list_definitions and use a value matching the setting's schema.` }, { cause: error });
97
118
  if (error instanceof ForbiddenError || error instanceof PermissionDeniedError)
98
- throw new ToolInputError({ message: `${toolId}: permission '${permission}' is required. Ask the owner to grant it before retrying.` });
119
+ throw new ToolInputError({ message: `${toolId}: permission '${permission}' is required. Ask the owner to grant it before retrying.` }, { cause: error });
99
120
  throw error;
100
121
  }
122
+ /**
123
+ * Builds `write-service.ts`'s own five-field dep shape from `RouteDeps`.
124
+ *
125
+ * Deliberately duplicates a host's own HTTP admin layer's equivalent field-mapping helper rather
126
+ * than importing it, for the reason `tool-registrations.ts`'s header already gives about
127
+ * `resolveOwnOrOtherPrincipalRead`: this is domain-owned wiring, and importing from a host's HTTP
128
+ * admin layer would invert the ports/adapters direction. It is a field mapping, not logic, so the
129
+ * two copies carry no behavioral drift risk — and `write-service.ts` is the single chokepoint
130
+ * either way, so a divergence here could only produce a missing dependency, which fails loudly.
131
+ */
132
+ export function toWriteDeps(routeDeps) {
133
+ return {
134
+ repo: routeDeps.settingsRepo,
135
+ clock: routeDeps.clock,
136
+ ids: routeDeps.idGen,
137
+ authorize: routeDeps.authorize,
138
+ principals: routeDeps.principalRepo,
139
+ };
140
+ }
141
+ /** Cancellation is checked at each step that precedes a card or a write. O(1). */
142
+ function assertRunActive(ctx, toolId) {
143
+ if (ctx.signal.aborted)
144
+ throw new ToolInputError({ message: `${toolId}: the run ended before the change could be applied. Nothing was changed.` });
145
+ }
146
+ /** Effects: policy -> readiness -> authorize -> definition preflight -> raw read. No writes. */
147
+ async function prepareWrite(deps, request) {
148
+ const { ctx, toolId, target, permission } = request;
149
+ assertRunActive(ctx, toolId);
150
+ writePolicy(deps, toolId, target);
151
+ await Promise.all([deps.settingsReady, deps.settingsUiTabsReady]);
152
+ await requireToolPermission({ authorize: adaptLegacyAuthorize({ authorize: deps.authorize }), workspaceId: deps.workspaceId, principalId: ctx.principal.id, permission }, { entityType: "setting-value" });
153
+ const definition = await resolveTarget(deps, target, toolId === "settings_set_value");
154
+ const rule = writePolicy(deps, toolId, target, definition);
155
+ const layer = await readLayer(deps, target, definition.settingId, ctx.principal.id);
156
+ const previous = layer?.state === "set" ? structuredClone(layer.valueJson) : null;
157
+ return { definition, rule, layer, previous };
158
+ }
159
+ /** Opens the host's human card for a protected key, then proves nothing changed while it was open.
160
+ * Fails closed without a confirmation port, on any answer but `true`, and on a stale definition or layer. */
161
+ async function confirmProtectedWrite(deps, request, prepared, options) {
162
+ const { ctx, toolId, target } = request;
163
+ const { definition, layer, previous, rule } = prepared;
164
+ const isSet = toolId === "settings_set_value";
165
+ if (!deps.confirmWrite)
166
+ throw new ToolInputError({ message: `${toolId}: '${rule.namespace}.${rule.key}' requires a human confirmation card: ${rule.reason}. The owner can change it in Settings.` });
167
+ const confirmed = await deps.confirmWrite({ ctx, toolId, namespace: rule.namespace, key: rule.key, scope: target.scope, previous: structuredClone(previous), ...(isSet ? { value: structuredClone(target.value) } : {}), reason: rule.reason }, options);
168
+ if (confirmed !== true)
169
+ throw new ToolInputError({ message: `${toolId}: the human did not confirm the change. Nothing was changed.` });
170
+ const current = await resolveTarget(deps, target, isSet);
171
+ const currentLayer = await readLayer(deps, target, current.settingId, ctx.principal.id);
172
+ if (current.settingId !== definition.settingId || current.version !== definition.version || currentLayer?.seq !== layer?.seq)
173
+ throw new ToolInputError({ message: `${toolId}: this setting changed while the confirmation card was open. Read it again and request a new confirmation. Nothing was changed by this call.` });
174
+ }
175
+ /** The service write itself; `write-service.ts` rechecks authorization inside its transaction. */
176
+ async function applyWrite(deps, request, prepared) {
177
+ const { ctx, toolId, target } = request;
178
+ const { definition, previous } = prepared;
179
+ const writeDeps = toWriteDeps(deps);
180
+ // Resolved coordinates preserve host validators even when the request named an alias.
181
+ const input = { namespace: definition.namespace, key: definition.key, scope: target.scope, workspaceId: deps.workspaceId, callerPrincipalId: ctx.principal.id, authWorkspaceId: deps.workspaceId };
182
+ const key = `${target.namespace}.${target.key}`;
183
+ if (toolId === "settings_set_value") {
184
+ const result = await (deps.setValue ?? set)({ deps: writeDeps, input: { ...input, value: target.value } });
185
+ return { key, scope: target.scope, previous, value: result.value, revisionSeq: result.revisionSeq };
186
+ }
187
+ await clear({ deps: writeDeps, input });
188
+ const effective = await getEffective({ repo: deps.settingsRepo }, { namespace: definition.namespace, key: definition.key, scopeContext: { workspaceId: deps.workspaceId, principalId: ctx.principal.id } });
189
+ return { key, scope: target.scope, previous, effective: effective?.value ?? null };
190
+ }
101
191
  /** Builds one value-write handler. Effects: authorize -> raw read -> optional human card ->
102
192
  * service write -> result. The service rechecks authorization and performs its normal transaction.
103
193
  * @complexity O(value size + rule count) plus bounded repository calls and a human wait.
@@ -109,45 +199,16 @@ export function buildSettingsValueWriteHandler(deps, toolId) {
109
199
  ctx = { ...ctx, principal: { ...ctx.principal }, run: { ...ctx.run } };
110
200
  const target = parseTarget(toolId, ctx.input);
111
201
  const permission = deriveRequiredPermission({ scope: target.scope, callerPrincipalId: ctx.principal.id });
112
- const isSet = toolId === "settings_set_value";
202
+ const request = { ctx, toolId, target, permission };
113
203
  try {
114
- if (ctx.signal.aborted)
115
- throw new ToolInputError({ message: `${toolId}: the run ended before the change could be applied. Nothing was changed.` });
116
- writePolicy(deps, toolId, target);
117
- await Promise.all([deps.settingsReady, deps.settingsUiTabsReady]);
118
- await requireToolPermission({ authorize: adaptLegacyAuthorize({ authorize: deps.authorize }), workspaceId: deps.workspaceId, principalId: ctx.principal.id, permission }, { entityType: "setting-value" });
119
- const definition = await resolveTarget(deps, target, isSet);
120
- const rule = writePolicy(deps, toolId, target, definition);
121
- const layer = await readLayer(deps, target, definition.settingId, ctx.principal.id);
122
- const previous = layer?.state === "set" ? structuredClone(layer.valueJson) : null;
204
+ const prepared = await prepareWrite(deps, request);
123
205
  // Cancellation may have arrived during readiness, authorization or the raw read.
124
206
  // Check before opening a card so a finished run cannot leave a new exchange waiting.
125
- if (ctx.signal.aborted)
126
- throw new ToolInputError({ message: `${toolId}: the run ended before the change could be applied. Nothing was changed.` });
127
- if (rule) {
128
- if (!deps.confirmWrite)
129
- throw new ToolInputError({ message: `${toolId}: '${rule.namespace}.${rule.key}' requires a human confirmation card: ${rule.reason}. The owner can change it in Settings.` });
130
- const confirmed = await deps.confirmWrite({ ctx, toolId, namespace: rule.namespace, key: rule.key, scope: target.scope, previous: structuredClone(previous), ...(isSet ? { value: structuredClone(target.value) } : {}), reason: rule.reason }, options);
131
- if (confirmed !== true)
132
- throw new ToolInputError({ message: `${toolId}: the human did not confirm the change. Nothing was changed.` });
133
- const current = await resolveTarget(deps, target, isSet);
134
- const currentLayer = await readLayer(deps, target, current.settingId, ctx.principal.id);
135
- if (current.settingId !== definition.settingId || current.version !== definition.version || currentLayer?.seq !== layer?.seq)
136
- throw new ToolInputError({ message: `${toolId}: this setting changed while the confirmation card was open. Read it again and request a new confirmation. Nothing was changed by this call.` });
137
- }
138
- if (ctx.signal.aborted)
139
- throw new ToolInputError({ message: `${toolId}: the run ended before the change could be applied. Nothing was changed.` });
140
- const writeDeps = { repo: deps.settingsRepo, clock: deps.clock, ids: deps.idGen, authorize: deps.authorize, principals: deps.principalRepo };
141
- // Resolved coordinates preserve host validators even when the request named an alias.
142
- const input = { namespace: definition.namespace, key: definition.key, scope: target.scope, workspaceId: deps.workspaceId, callerPrincipalId: ctx.principal.id, authWorkspaceId: deps.workspaceId };
143
- const key = `${target.namespace}.${target.key}`;
144
- if (isSet) {
145
- const result = await (deps.setValue ?? set)({ deps: writeDeps, input: { ...input, value: target.value } });
146
- return { key, scope: target.scope, previous, value: result.value, revisionSeq: result.revisionSeq };
147
- }
148
- await clear({ deps: writeDeps, input });
149
- const effective = await getEffective({ repo: deps.settingsRepo }, { namespace: definition.namespace, key: definition.key, scopeContext: { workspaceId: deps.workspaceId, principalId: ctx.principal.id } });
150
- return { key, scope: target.scope, previous, effective: effective?.value ?? null };
207
+ assertRunActive(ctx, toolId);
208
+ if (prepared.rule)
209
+ await confirmProtectedWrite(deps, request, { ...prepared, rule: prepared.rule }, options);
210
+ assertRunActive(ctx, toolId);
211
+ return await applyWrite(deps, request, prepared);
151
212
  }
152
213
  catch (error) {
153
214
  rethrowWriteError(error, toolId, target, permission);
@@ -44,7 +44,8 @@ export interface SettingDefinitionSpec<Key extends string = string> {
44
44
  * Bitmask over `SCOPE_BIT`. Defaults to `workspace`, which is what most
45
45
  * settings-dialog tabs want: one operator's choice shouldn't silently
46
46
  * become every workspace's. Pass `SCOPE_BIT.user` (or a mask including it)
47
- * for a genuinely per-operator preference.
47
+ * for a genuinely per-operator preference. On existing core definitions, explicitly
48
+ * declared bits are added at boot; existing scopes and value rows are preserved.
48
49
  */
49
50
  scopes?: number;
50
51
  }
@@ -73,7 +74,8 @@ export type EnsureSettingDefinitionsInput = SettingDefinitionsInput & ({
73
74
  /**
74
75
  * Idempotently registers every definition in `input.definitions` under
75
76
  * `input.namespace`, and reconciles the stored default of any that already
76
- * exist to the default source declares. Safe to call on every boot.
77
+ * exist to the default source declares. Explicit core scope bits are added without removing
78
+ * existing scopes or moving stored values. Safe to call on every boot.
77
79
  *
78
80
  * The reconcile half matters as much as the register half: registration is
79
81
  * once-only, so without it a `defaultValue` edited in source never reaches an
@@ -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. Safe to call on every boot.
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 authWorkspaceId = workspaceId ?? input.systemPrincipalId;
37
- const writeDeps = {
38
- repo: deps.settingsRepo,
39
- clock: deps.clock,
40
- ids: deps.ids,
41
- authorize: alwaysAllowBoot,
42
- principals: deps.principals,
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: input.namespace, key: def.key, workspaceId });
46
- if (existing) {
47
- // Core registration is once-only, but the DEFAULT is not. Without this, a
48
- // `defaultValue` changed in source is unreachable in any install that
49
- // has already booted — the stored `default_json` from first boot keeps
50
- // winning, for every settings-dialog tab. See `reconcileDefinitionDefault`
51
- // for the bug this closes.
52
- //
53
- // Three slots are deliberately left alone rather than reconciled:
54
- // a non-`active` row (deprecated/tombstoned) is not ours to rewrite; a
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
@@ -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 {
@@ -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
- /** Bind the CMS ledger and write chokepoint without copying their domain logic.
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
- /** Bind the CMS ledger and write chokepoint without copying their domain logic.
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
- const op = String(item.op ?? "register");
81
- const namespace = String(item.namespace ?? "");
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
- const opItem = { namespace, key, ownerKind, workspaceId, defaultJson: defaultValue,
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