@jini-ai/core 0.4.7 → 0.4.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/dist/agent-tools.d.ts +1 -1
  3. package/dist/approval.d.ts +15 -0
  4. package/dist/approval.js +87 -22
  5. package/dist/batch-tool-inputs.d.ts +147 -0
  6. package/dist/batch-tool-inputs.js +257 -0
  7. package/dist/feature-flags.d.ts +21 -0
  8. package/dist/feature-flags.js +17 -0
  9. package/dist/fetch/index.d.ts +2 -1
  10. package/dist/fetch/index.js +1 -0
  11. package/dist/fetch/loader-read.d.ts +16 -0
  12. package/dist/fetch/loader-read.js +22 -0
  13. package/dist/fetch/types.d.ts +12 -2
  14. package/dist/gated-mutations/gateway.js +32 -27
  15. package/dist/index.d.ts +9 -1
  16. package/dist/index.js +8 -1
  17. package/dist/object-store.d.ts +115 -0
  18. package/dist/object-store.js +70 -0
  19. package/dist/origin-validation.js +64 -57
  20. package/dist/primitives/http.d.ts +2 -2
  21. package/dist/primitives/index.d.ts +1 -0
  22. package/dist/primitives/index.js +1 -0
  23. package/dist/primitives/paths.js +17 -10
  24. package/dist/primitives/tasks.d.ts +10 -0
  25. package/dist/primitives/tasks.js +8 -0
  26. package/dist/provider-catalog.d.ts +76 -0
  27. package/dist/provider-catalog.js +6 -0
  28. package/dist/provider-registry.d.ts +144 -0
  29. package/dist/provider-registry.js +138 -0
  30. package/dist/records.d.ts +24 -0
  31. package/dist/records.js +25 -0
  32. package/dist/redact.d.ts +4 -0
  33. package/dist/redact.js +57 -34
  34. package/dist/registration-kit.d.ts +7 -4
  35. package/dist/registration-kit.js +81 -62
  36. package/dist/settings/agent-value-write-tools.d.ts +19 -0
  37. package/dist/settings/agent-value-write-tools.js +131 -70
  38. package/dist/settings/ensure-definitions.d.ts +4 -2
  39. package/dist/settings/ensure-definitions.js +79 -63
  40. package/dist/settings/express/cms-adapter.d.ts +2 -2
  41. package/dist/settings/express/cms-adapter.js +34 -22
  42. package/dist/settings/express/http-request.d.ts +33 -0
  43. package/dist/settings/express/http-request.js +49 -0
  44. package/dist/settings/express/http.d.ts +2 -2
  45. package/dist/settings/express/http.js +16 -39
  46. package/dist/settings/express/stream.d.ts +2 -2
  47. package/dist/settings/express/stream.js +49 -27
  48. package/dist/settings/repo.memory.js +7 -4
  49. package/dist/settings/settings.js +54 -60
  50. package/dist/settings/tool-registrations.d.ts +2 -3
  51. package/dist/settings/tool-registrations.js +64 -39
  52. package/dist/settings/write-service.d.ts +19 -0
  53. package/dist/settings/write-service.js +34 -0
  54. package/dist/target-scope.d.ts +52 -0
  55. package/dist/target-scope.js +82 -0
  56. package/dist/text/escape.js +2 -2
  57. package/dist/text/sanitize-untrusted-text.d.ts +5 -0
  58. package/dist/text/sanitize-untrusted-text.js +55 -35
  59. package/dist/tool-exposure.d.ts +79 -0
  60. package/dist/tool-exposure.js +163 -0
  61. package/dist/tool-input-errors.d.ts +23 -0
  62. package/dist/tool-input-errors.js +24 -0
  63. package/dist/tool-input-normalization.d.ts +37 -0
  64. package/dist/tool-input-normalization.js +188 -0
  65. package/dist/tool-metadata-types.d.ts +26 -0
  66. package/dist/tool-metadata-types.js +2 -0
  67. package/dist/tool-metadata.d.ts +2 -23
  68. package/dist/tool-registry.d.ts +38 -22
  69. package/dist/tool-registry.js +35 -20
  70. package/package.json +20 -2
  71. package/plugin-directory.js +72 -33
@@ -1,13 +1,14 @@
1
1
  import { resolveDefinitionRaw } from "./settings.js";
2
2
  import { SCOPE_BIT } from "./types.js";
3
- import { reconcileDefinitionDefault, registerDefinitions } from "./write-service.js";
3
+ import { extendDefinitionScopes, reconcileDefinitionDefault, registerDefinitions } from "./write-service.js";
4
4
  /** Boot-time infra work is trusted by construction — mirrors every other
5
5
  * `ensure*SettingDefinitions`'s identical shim. */
6
6
  const alwaysAllowBoot = async () => ({ allowed: true, reason: "system_boot" });
7
7
  /**
8
8
  * Idempotently registers every definition in `input.definitions` under
9
9
  * `input.namespace`, and reconciles the stored default of any that already
10
- * exist to the default source declares. 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
@@ -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
@@ -0,0 +1,49 @@
1
+ import { respondToSettingsError } from "./shared.js";
2
+ /**
3
+ * @file Request-level steps of the settings HTTP registrar (`http.ts`): the
4
+ * authentication gate, the failure envelope and value-write body parsing.
5
+ * Pure or response-only; no service, authorization or storage effects.
6
+ */
7
+ export const SETTING_SCOPES = ["global", "workspace", "user"];
8
+ export function unauthenticated(res) {
9
+ res.status(401).json({ error: "unauthenticated", code: "UNAUTHENTICATED" });
10
+ }
11
+ /** Readiness/principal failures preserve the authentication boundary's existing 401 envelope, so both collapse to null. */
12
+ export async function resolvePrincipal(required, context) {
13
+ try {
14
+ await required.ready;
15
+ return await required.principalResolver(context);
16
+ }
17
+ catch {
18
+ return null;
19
+ }
20
+ }
21
+ /** Map a handler failure to its envelope, or end a response whose headers have already been sent. */
22
+ export function failRequest(required, optional) {
23
+ const { response, error, mappings } = required;
24
+ if (!response.headersSent) {
25
+ respondToSettingsError({ response, error, mappings });
26
+ return;
27
+ }
28
+ // Reporting cannot prevent ending a response whose headers have already been sent.
29
+ try {
30
+ optional.onError?.({ operation: "settings-request", error });
31
+ }
32
+ catch { /* Host diagnostics are best-effort. */ }
33
+ finally {
34
+ response.end();
35
+ }
36
+ }
37
+ export const VALUE_MUTATION_REQUIRED = {
38
+ set: "namespace, key, scope (global|workspace|user), and valueJson are required",
39
+ clear: "namespace, key, and scope (global|workspace|user) are required",
40
+ };
41
+ /** The fields a value write needs, or null when one is missing or the scope is unknown. Pure, O(1). */
42
+ export function readValueMutation(body, operation) {
43
+ const namespace = String(body.namespace ?? "");
44
+ const key = String(body.key ?? "");
45
+ const scope = body.scope;
46
+ const complete = namespace && key && SETTING_SCOPES.includes(scope) && (operation === "clear" || "valueJson" in body);
47
+ return complete ? { namespace, key, scope } : null;
48
+ }
49
+ //# sourceMappingURL=http-request.js.map
@@ -1,6 +1,6 @@
1
1
  import type { SettingsHttpRequired, SettingsHttpOptions } from "./contracts.js";
2
- /** Mount all eight endpoints. The returned disposer closes active feeds and cancels their schedules. */
3
- /** Injected policy and services preserve the settings HTTP response envelopes.
2
+ /** Mount all eight endpoints. The returned disposer closes active feeds and cancels their schedules.
3
+ * Injected policy and services preserve the settings HTTP response envelopes.
4
4
  * O(1) mounting; read/mutation work is delegated. Feed resources are bounded per connection.
5
5
  * @example registerSettingsRoutes({ app, workspaceId, ready, service, changeFeed, principalResolver, authorize, scheduler, routes, permissions });
6
6
  */
@@ -1,6 +1,7 @@
1
1
  import { AliasDepthExceededError, DefinitionInvalidError, DefinitionNotFoundError, DefinitionTombstonedError, ForbiddenError, PrincipalNotFoundError, RenameRetypeConflictError, ScopeNotAllowedError, SecretNotSupportedError, ValueValidationFailedError, } from "../index.js";
2
- import { respondToSettingsError, resolveTargetWorkspaceId, resolveUserLayerReadTarget, settingsWritePermission } from "./shared.js";
2
+ import { resolveTargetWorkspaceId, resolveUserLayerReadTarget, settingsWritePermission } from "./shared.js";
3
3
  import { openSettingsChangeStream } from "./stream.js";
4
+ import { failRequest, readValueMutation, resolvePrincipal, SETTING_SCOPES, unauthenticated, VALUE_MUTATION_REQUIRED } from "./http-request.js";
4
5
  const VALUE_ERRORS = [
5
6
  { matches: (e) => e instanceof PrincipalNotFoundError, status: 404, code: "PRINCIPAL_NOT_FOUND" },
6
7
  { matches: (e) => e instanceof DefinitionNotFoundError, status: 404, code: "DEFINITION_NOT_FOUND" },
@@ -18,7 +19,6 @@ const DEFINITION_ERRORS = [
18
19
  { matches: (e) => e instanceof ForbiddenError, status: 403, code: "FORBIDDEN" },
19
20
  ];
20
21
  const RESET_ERRORS = VALUE_ERRORS.filter((m) => m.code === "FORBIDDEN");
21
- const scopes = ["global", "workspace", "user"];
22
22
  function validation(res, error) {
23
23
  res.status(400).json({ error, code: "VALIDATION_ERROR" });
24
24
  }
@@ -29,8 +29,8 @@ function denied(res, principalId, permission, reason) {
29
29
  function bodyOf(req) {
30
30
  return req.body && typeof req.body === "object" && !Array.isArray(req.body) ? req.body : {};
31
31
  }
32
- /** Mount all eight endpoints. The returned disposer closes active feeds and cancels their schedules. */
33
- /** Injected policy and services preserve the settings HTTP response envelopes.
32
+ /** Mount all eight endpoints. The returned disposer closes active feeds and cancels their schedules.
33
+ * Injected policy and services preserve the settings HTTP response envelopes.
34
34
  * O(1) mounting; read/mutation work is delegated. Feed resources are bounded per connection.
35
35
  * @example registerSettingsRoutes({ app, workspaceId, ready, service, changeFeed, principalResolver, authorize, scheduler, routes, permissions });
36
36
  */
@@ -47,36 +47,16 @@ export function registerSettingsRoutes(required, optional = {}) {
47
47
  res.status(404).json({ error: "workspace was not found" });
48
48
  return;
49
49
  }
50
- let principal;
51
- try {
52
- await required.ready;
53
- principal = await required.principalResolver({ request: req, response: res });
54
- }
55
- catch {
56
- // Readiness/principal failures preserve the authentication boundary's existing 401 envelope.
57
- res.status(401).json({ error: "unauthenticated", code: "UNAUTHENTICATED" });
58
- return;
59
- }
50
+ const principal = await resolvePrincipal(required, { request: req, response: res });
60
51
  if (!principal) {
61
- res.status(401).json({ error: "unauthenticated", code: "UNAUTHENTICATED" });
52
+ unauthenticated(res);
62
53
  return;
63
54
  }
64
55
  try {
65
56
  await handler(req, res, principal.id);
66
57
  }
67
58
  catch (error) {
68
- if (res.headersSent) {
69
- // Reporting cannot prevent ending a response whose headers have already been sent.
70
- try {
71
- optional.onError?.({ operation: "settings-request", error });
72
- }
73
- catch { /* Host diagnostics are best-effort. */ }
74
- finally {
75
- res.end();
76
- }
77
- }
78
- else
79
- respondToSettingsError({ response: res, error, mappings });
59
+ failRequest({ response: res, error, mappings }, optional);
80
60
  }
81
61
  };
82
62
  const check = async (res, principalId, permission, entityType) => {
@@ -152,13 +132,12 @@ export function registerSettingsRoutes(required, optional = {}) {
152
132
  }, DEFINITION_ERRORS));
153
133
  const mutate = (operation) => async (req, res, callerPrincipalId) => {
154
134
  const body = bodyOf(req);
155
- const namespace = String(body.namespace ?? "");
156
- const key = String(body.key ?? "");
157
- const scope = body.scope;
158
- if (!namespace || !key || !scopes.includes(scope) || (operation === "set" && !("valueJson" in body))) {
159
- validation(res, operation === "set" ? "namespace, key, scope (global|workspace|user), and valueJson are required" : "namespace, key, and scope (global|workspace|user) are required");
135
+ const fields = readValueMutation(body, operation);
136
+ if (!fields) {
137
+ validation(res, VALUE_MUTATION_REQUIRED[operation]);
160
138
  return;
161
139
  }
140
+ const { namespace, key, scope } = fields;
162
141
  const target = resolveTargetWorkspaceId({ workspaceId, input: { bodyWorkspaceId: body.workspaceId, scope } });
163
142
  if (!target.ok) {
164
143
  validation(res, target.error);
@@ -178,7 +157,7 @@ export function registerSettingsRoutes(required, optional = {}) {
178
157
  const body = bodyOf(req);
179
158
  const namespace = String(body.namespace ?? "");
180
159
  const scope = body.scope;
181
- if (!namespace || !scopes.includes(scope)) {
160
+ if (!namespace || !SETTING_SCOPES.includes(scope)) {
182
161
  validation(res, "namespace and scope (global|workspace|user) are required");
183
162
  return;
184
163
  }
@@ -198,12 +177,10 @@ export function registerSettingsRoutes(required, optional = {}) {
198
177
  const close = await openSettingsChangeStream({ request: req, response: res, workspaceId, principalId,
199
178
  changeFeed: required.changeFeed, authorize: required.authorize, permission: permissions.read, scheduler: required.scheduler,
200
179
  onClose: ({ close }) => { streams.delete(close); }, isDisposed: () => disposed }, optional);
201
- if (close) {
202
- if (disposed)
203
- close();
204
- else if (!res.destroyed && !res.writableEnded)
205
- streams.add(close);
206
- }
180
+ if (disposed)
181
+ close();
182
+ else if (!res.destroyed && !res.writableEnded)
183
+ streams.add(close);
207
184
  }));
208
185
  return { dispose: () => { disposed = true; for (const close of streams)
209
186
  close(); streams.clear(); } };
@@ -14,8 +14,8 @@ interface StreamRequired {
14
14
  }) => void;
15
15
  isDisposed: () => boolean;
16
16
  }
17
- /** Starts after HTTP authorization. All scheduling and storage are injected. */
18
- /** Poll with bounded pages, tenant-scoped ids and retryable reads; close cancels all schedules.
17
+ /** Starts after HTTP authorization. All scheduling and storage are injected.
18
+ * Poll with bounded pages, tenant-scoped ids and retryable reads; close cancels all schedules.
19
19
  * O(page size) per tick and O(1) connection state; repository effects live behind the feed port.
20
20
  * Invalid scheduler intervals/page sizes throw RangeError before opening; initial read errors propagate.
21
21
  */
@@ -1,29 +1,16 @@
1
- /** Starts after HTTP authorization. All scheduling and storage are injected. */
2
- /** Poll with bounded pages, tenant-scoped ids and retryable reads; close cancels all schedules.
1
+ /** Starts after HTTP authorization. All scheduling and storage are injected.
2
+ * Poll with bounded pages, tenant-scoped ids and retryable reads; close cancels all schedules.
3
3
  * O(page size) per tick and O(1) connection state; repository effects live behind the feed port.
4
4
  * Invalid scheduler intervals/page sizes throw RangeError before opening; initial read errors propagate.
5
5
  */
6
6
  export async function openSettingsChangeStream(required, optional = {}) {
7
7
  const { request, response, workspaceId, principalId, changeFeed, scheduler } = required;
8
- const pageSize = optional.revisionPageSize ?? 200;
9
- const intervals = [optional.pollIntervalMs ?? 1_000, optional.keepaliveIntervalMs ?? 25_000, optional.reauthorizeIntervalMs ?? 30_000];
10
- if (!Number.isSafeInteger(pageSize) || pageSize <= 0 || intervals.some((n) => !Number.isFinite(n) || n <= 0)) {
11
- throw new RangeError("settings feed page size and intervals must be positive");
12
- }
8
+ const { pageSize, pollIntervalMs, keepaliveIntervalMs, reauthorizeIntervalMs } = readStreamTimings(optional);
13
9
  let closed = false;
14
10
  let ticking = false;
15
11
  let checkingAuthorization = false;
16
12
  const cancellations = [];
17
- const reportError = (operation, error) => {
18
- // Reporting is best-effort: a host hook must never turn recovery into an unhandled rejection.
19
- try {
20
- if (optional.onError)
21
- optional.onError({ operation, error });
22
- else
23
- console.error(`[settings] change feed ${operation} failed`, error);
24
- }
25
- catch { /* Diagnostic failures cannot interrupt stream cleanup or retries. */ }
26
- };
13
+ const reportError = createErrorReporter(optional);
27
14
  const recover = (operation, effect) => {
28
15
  try {
29
16
  effect();
@@ -52,18 +39,15 @@ export async function openSettingsChangeStream(required, optional = {}) {
52
39
  response.on("close", close);
53
40
  try {
54
41
  const head = await changeFeed.head({});
55
- if (closed || required.isDisposed() || response.destroyed || response.writableEnded) {
42
+ const gone = () => closed || required.isDisposed() || response.destroyed || response.writableEnded;
43
+ if (gone()) {
56
44
  close();
57
45
  return close;
58
46
  }
59
- const resumeFrom = Number(request.headers["last-event-id"]);
60
- let cursor = Number.isFinite(resumeFrom) && resumeFrom >= 0 ? Math.min(resumeFrom, head) : head;
47
+ let cursor = resumeCursor({ lastEventId: request.headers["last-event-id"], head });
61
48
  let lastEmittedId = cursor;
62
49
  const viewer = { workspaceId, principalId };
63
- response.writeHead(200, {
64
- "Content-Type": "text/event-stream", "Cache-Control": "no-cache, no-transform",
65
- ...(request.httpVersionMajor < 2 ? { Connection: "keep-alive" } : {}), "X-Accel-Buffering": "no",
66
- });
50
+ response.writeHead(200, sseHeaders({ httpVersionMajor: request.httpVersionMajor }));
67
51
  const tick = async () => {
68
52
  if (closed || ticking)
69
53
  return;
@@ -116,8 +100,8 @@ export async function openSettingsChangeStream(required, optional = {}) {
116
100
  else
117
101
  cancellations.push(cancel);
118
102
  };
119
- schedule(intervals[0], () => { void tick(); });
120
- schedule(intervals[1], () => {
103
+ schedule(pollIntervalMs, () => { void tick(); });
104
+ schedule(keepaliveIntervalMs, () => {
121
105
  if (closed)
122
106
  return;
123
107
  try {
@@ -128,7 +112,7 @@ export async function openSettingsChangeStream(required, optional = {}) {
128
112
  close();
129
113
  }
130
114
  });
131
- schedule(intervals[2], () => { void reauthorize(); });
115
+ schedule(reauthorizeIntervalMs, () => { void reauthorize(); });
132
116
  if (!closed)
133
117
  response.write(": connected\n\n");
134
118
  return close;
@@ -144,4 +128,42 @@ export async function openSettingsChangeStream(required, optional = {}) {
144
128
  throw error;
145
129
  }
146
130
  }
131
+ /** Fill the documented defaults, then refuse any non-positive page size or interval before an effect. Pure. */
132
+ function readStreamTimings(optional) {
133
+ const timings = {
134
+ pageSize: optional.revisionPageSize ?? 200,
135
+ pollIntervalMs: optional.pollIntervalMs ?? 1_000,
136
+ keepaliveIntervalMs: optional.keepaliveIntervalMs ?? 25_000,
137
+ reauthorizeIntervalMs: optional.reauthorizeIntervalMs ?? 30_000,
138
+ };
139
+ const intervals = [timings.pollIntervalMs, timings.keepaliveIntervalMs, timings.reauthorizeIntervalMs];
140
+ if (!Number.isSafeInteger(timings.pageSize) || timings.pageSize <= 0 || intervals.some((n) => !Number.isFinite(n) || n <= 0)) {
141
+ throw new RangeError("settings feed page size and intervals must be positive");
142
+ }
143
+ return timings;
144
+ }
145
+ function createErrorReporter(optional) {
146
+ return (operation, error) => {
147
+ // Reporting is best-effort: a host hook must never turn recovery into an unhandled rejection.
148
+ try {
149
+ if (optional.onError)
150
+ optional.onError({ operation, error });
151
+ else
152
+ console.error(`[settings] change feed ${operation} failed`, error);
153
+ }
154
+ catch { /* Diagnostic failures cannot interrupt stream cleanup or retries. */ }
155
+ };
156
+ }
157
+ /** A client's Last-Event-ID resumes at or before the ledger head; a missing or unusable id starts at head. Pure. */
158
+ function resumeCursor(required) {
159
+ const resumeFrom = Number(required.lastEventId);
160
+ return Number.isFinite(resumeFrom) && resumeFrom >= 0 ? Math.min(resumeFrom, required.head) : required.head;
161
+ }
162
+ /** HTTP/2 forbids the Connection header; HTTP/1 needs it to keep the stream open. Pure. */
163
+ function sseHeaders(required) {
164
+ return {
165
+ "Content-Type": "text/event-stream", "Cache-Control": "no-cache, no-transform",
166
+ ...(required.httpVersionMajor < 2 ? { Connection: "keep-alive" } : {}), "X-Accel-Buffering": "no",
167
+ };
168
+ }
147
169
  //# sourceMappingURL=stream.js.map
@@ -179,8 +179,11 @@ export class InMemorySettingsRepo {
179
179
  nextSeq: this.nextSeq,
180
180
  };
181
181
  this.inTransaction = true;
182
+ // No `finally`: the flag is released on both exits explicitly (V8 coverage counts the
183
+ // unreachable continuation after a catch-block `throw` inside try/finally as a missed branch).
184
+ let result;
182
185
  try {
183
- return await fn();
186
+ result = await fn();
184
187
  }
185
188
  catch (error) {
186
189
  this.definitions = snapshot.definitions;
@@ -189,11 +192,11 @@ export class InMemorySettingsRepo {
189
192
  this.userValues = snapshot.userValues;
190
193
  this.revisions = snapshot.revisions;
191
194
  this.nextSeq = snapshot.nextSeq;
192
- throw error;
193
- }
194
- finally {
195
195
  this.inTransaction = false;
196
+ throw error;
196
197
  }
198
+ this.inTransaction = false;
199
+ return result;
197
200
  }
198
201
  }
199
202
  //# sourceMappingURL=repo.memory.js.map