@jini-ai/core 0.4.1 → 0.4.2

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 (118) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/README.md +18 -1
  3. package/dist/agent-tools.d.ts +1 -0
  4. package/dist/approval.d.ts +134 -0
  5. package/dist/approval.js +141 -0
  6. package/dist/index.d.ts +5 -0
  7. package/dist/index.js +5 -0
  8. package/dist/model-facing-tool-errors.d.ts +152 -0
  9. package/dist/model-facing-tool-errors.js +124 -0
  10. package/dist/native-regexp.d.ts +10 -0
  11. package/dist/native-regexp.js +2 -0
  12. package/dist/primitives/index.d.ts +2 -0
  13. package/dist/primitives/index.js +1 -0
  14. package/dist/primitives/sse.d.ts +60 -0
  15. package/dist/primitives/sse.js +122 -0
  16. package/dist/redact.js +4 -3
  17. package/dist/registration-kit.d.ts +13 -0
  18. package/dist/registration-kit.js +12 -0
  19. package/dist/settings/agent-tools.d.ts +10 -0
  20. package/dist/settings/agent-tools.js +204 -0
  21. package/dist/settings/agent-value-write-tools.d.ts +22 -0
  22. package/dist/settings/agent-value-write-tools.js +157 -0
  23. package/dist/settings/agent-writable-preferences.d.ts +67 -0
  24. package/dist/settings/agent-writable-preferences.js +132 -0
  25. package/dist/settings/agent-write-denylist.d.ts +19 -0
  26. package/dist/settings/agent-write-denylist.js +15 -0
  27. package/dist/settings/change-feed.d.ts +83 -0
  28. package/dist/settings/change-feed.js +61 -0
  29. package/dist/settings/definitions-dispatch.d.ts +56 -0
  30. package/dist/settings/definitions-dispatch.js +86 -0
  31. package/dist/settings/dictionaries/index.d.ts +48 -0
  32. package/dist/settings/dictionaries/index.js +69 -0
  33. package/dist/settings/dictionaries/settings-dialog.ar.d.ts +3 -0
  34. package/dist/settings/dictionaries/settings-dialog.ar.js +27 -0
  35. package/dist/settings/dictionaries/settings-dialog.bn.d.ts +3 -0
  36. package/dist/settings/dictionaries/settings-dialog.bn.js +27 -0
  37. package/dist/settings/dictionaries/settings-dialog.de.d.ts +3 -0
  38. package/dist/settings/dictionaries/settings-dialog.de.js +27 -0
  39. package/dist/settings/dictionaries/settings-dialog.es.d.ts +3 -0
  40. package/dist/settings/dictionaries/settings-dialog.es.js +27 -0
  41. package/dist/settings/dictionaries/settings-dialog.fa.d.ts +3 -0
  42. package/dist/settings/dictionaries/settings-dialog.fa.js +27 -0
  43. package/dist/settings/dictionaries/settings-dialog.fr.d.ts +3 -0
  44. package/dist/settings/dictionaries/settings-dialog.fr.js +27 -0
  45. package/dist/settings/dictionaries/settings-dialog.hi.d.ts +3 -0
  46. package/dist/settings/dictionaries/settings-dialog.hi.js +27 -0
  47. package/dist/settings/dictionaries/settings-dialog.hu.d.ts +3 -0
  48. package/dist/settings/dictionaries/settings-dialog.hu.js +27 -0
  49. package/dist/settings/dictionaries/settings-dialog.id.d.ts +3 -0
  50. package/dist/settings/dictionaries/settings-dialog.id.js +27 -0
  51. package/dist/settings/dictionaries/settings-dialog.it.d.ts +3 -0
  52. package/dist/settings/dictionaries/settings-dialog.it.js +27 -0
  53. package/dist/settings/dictionaries/settings-dialog.ja.d.ts +3 -0
  54. package/dist/settings/dictionaries/settings-dialog.ja.js +27 -0
  55. package/dist/settings/dictionaries/settings-dialog.ko.d.ts +3 -0
  56. package/dist/settings/dictionaries/settings-dialog.ko.js +27 -0
  57. package/dist/settings/dictionaries/settings-dialog.pl.d.ts +3 -0
  58. package/dist/settings/dictionaries/settings-dialog.pl.js +27 -0
  59. package/dist/settings/dictionaries/settings-dialog.pt-BR.d.ts +3 -0
  60. package/dist/settings/dictionaries/settings-dialog.pt-BR.js +27 -0
  61. package/dist/settings/dictionaries/settings-dialog.ru.d.ts +3 -0
  62. package/dist/settings/dictionaries/settings-dialog.ru.js +27 -0
  63. package/dist/settings/dictionaries/settings-dialog.th.d.ts +3 -0
  64. package/dist/settings/dictionaries/settings-dialog.th.js +27 -0
  65. package/dist/settings/dictionaries/settings-dialog.tr.d.ts +3 -0
  66. package/dist/settings/dictionaries/settings-dialog.tr.js +27 -0
  67. package/dist/settings/dictionaries/settings-dialog.uk.d.ts +3 -0
  68. package/dist/settings/dictionaries/settings-dialog.uk.js +27 -0
  69. package/dist/settings/dictionaries/settings-dialog.ur.d.ts +3 -0
  70. package/dist/settings/dictionaries/settings-dialog.ur.js +27 -0
  71. package/dist/settings/dictionaries/settings-dialog.zh-CN.d.ts +3 -0
  72. package/dist/settings/dictionaries/settings-dialog.zh-CN.js +27 -0
  73. package/dist/settings/dictionaries/settings-dialog.zh-TW.d.ts +3 -0
  74. package/dist/settings/dictionaries/settings-dialog.zh-TW.js +27 -0
  75. package/dist/settings/ensure-definitions.d.ts +101 -0
  76. package/dist/settings/ensure-definitions.js +101 -0
  77. package/dist/settings/errors.d.ts +42 -0
  78. package/dist/settings/errors.js +46 -0
  79. package/dist/settings/express/cms-adapter.d.ts +20 -0
  80. package/dist/settings/express/cms-adapter.js +124 -0
  81. package/dist/settings/express/contracts.d.ts +135 -0
  82. package/dist/settings/express/contracts.js +2 -0
  83. package/dist/settings/express/http.d.ts +10 -0
  84. package/dist/settings/express/http.js +211 -0
  85. package/dist/settings/express/index.d.ts +5 -0
  86. package/dist/settings/express/index.js +4 -0
  87. package/dist/settings/express/shared.d.ts +68 -0
  88. package/dist/settings/express/shared.js +54 -0
  89. package/dist/settings/express/stream.d.ts +24 -0
  90. package/dist/settings/express/stream.js +147 -0
  91. package/dist/settings/index.d.ts +40 -0
  92. package/dist/settings/index.js +37 -0
  93. package/dist/settings/ports.d.ts +118 -0
  94. package/dist/settings/ports.js +2 -0
  95. package/dist/settings/principal-lookup.d.ts +9 -0
  96. package/dist/settings/principal-lookup.js +2 -0
  97. package/dist/settings/purge-service.d.ts +54 -0
  98. package/dist/settings/purge-service.js +106 -0
  99. package/dist/settings/repo.memory.d.ts +119 -0
  100. package/dist/settings/repo.memory.js +199 -0
  101. package/dist/settings/settings.d.ts +139 -0
  102. package/dist/settings/settings.js +259 -0
  103. package/dist/settings/tool-registrations.d.ts +54 -0
  104. package/dist/settings/tool-registrations.js +250 -0
  105. package/dist/settings/types.d.ts +125 -0
  106. package/dist/settings/types.js +3 -0
  107. package/dist/settings/ui-tab-definitions.d.ts +41 -0
  108. package/dist/settings/ui-tab-definitions.js +148 -0
  109. package/dist/settings/write-service.d.ts +320 -0
  110. package/dist/settings/write-service.js +683 -0
  111. package/dist/surface-exchanges.d.ts +209 -0
  112. package/dist/surface-exchanges.js +55 -0
  113. package/dist/tool-metadata.d.ts +71 -0
  114. package/dist/tool-metadata.js +56 -0
  115. package/dist/tool-permissions.d.ts +76 -0
  116. package/dist/tool-permissions.js +57 -0
  117. package/dist/tool-registry.d.ts +3 -0
  118. package/package.json +60 -3
@@ -0,0 +1,209 @@
1
+ /**
2
+ * Shared surface-exchange ABI. The daemon owns the store and ask/report implementation;
3
+ * consumers inject those ports without depending on the daemon runtime.
4
+ */
5
+ import type { SurfaceEmission, SurfaceEmitter } from './tool-registry.js';
6
+ /**
7
+ * The callback param carrying an exchange id from a rendered surface back to the server.
8
+ *
9
+ * Double-underscored to keep it clear of a surface's real fields, and named in one place because
10
+ * three parties must agree on it: whatever builds the surface, the route that routes on it, and the
11
+ * handler that opened the exchange.
12
+ *
13
+ * This is MCP-UI's carrier specifically, because an mcp-ui surface can only answer by issuing a tool
14
+ * call, so its correlation has to ride inside that call's params. A channel that can name the
15
+ * exchange directly should do so — `mcp-ui-tool-calls-route.ts` also accepts a top-level
16
+ * `exchangeId`, and A2UI/`surface_request` correlate by their own `surfaceId`.
17
+ */
18
+ export declare const SURFACE_EXCHANGE_ID_PARAM = "__exchangeId";
19
+ /**
20
+ * The callback param a surface's Cancel action sets, so a dismissal resolves the exchange
21
+ * immediately instead of stranding the agent until the idle deadline.
22
+ *
23
+ * Carried as data rather than as a distinct {@link SurfaceMessage} status: "the human answered, and
24
+ * the answer was no" is genuinely an answer, and only the tool that built the surface knows what a
25
+ * dismissal means for its own result.
26
+ */
27
+ export declare const SURFACE_DISMISSED_PARAM = "__dismissed";
28
+ /**
29
+ * The callback param carrying free text a human TYPED — into the chat composer — as their answer to
30
+ * an outstanding surface, rather than clicking the rendered form.
31
+ *
32
+ * ## The deadlock this exists to break
33
+ *
34
+ * A held-open exchange can only be resolved by a message that names it, and until now every param
35
+ * shape a surface posts back was one the FORM produced. A human who reads the question, ignores the
36
+ * dialog and types the answer into the composer therefore resolved nothing: their message was queued
37
+ * behind the very run it was meant to unblock, and the agent sat parked until the idle deadline
38
+ * fired minutes later.
39
+ *
40
+ * ## Why it is a separate param rather than the tool's own answer fields
41
+ *
42
+ * Prose is not a selection. A tool whose answer fields are option VALUES the model itself supplied
43
+ * (`assistant_ask_choice`'s `choice`/`selections`) would, if prose were poured into them, report to
44
+ * the model that the administrator picked an option that was never offered. Carrying typed text
45
+ * under its own name is what forces each tool to decide, explicitly, what a typed answer means for
46
+ * its own result — and lets a tool that has no safe reading of one simply ignore it.
47
+ *
48
+ * ## What it deliberately cannot do
49
+ *
50
+ * It cannot confirm anything. `classifyConfirmationAnswer` reads only a literal `decision:
51
+ * "confirm"`, so a typed answer arriving at a confirmation-shaped exchange fails closed to
52
+ * `"declined"` with no code change and no exception — which is the correct reading of "the human
53
+ * wrote a sentence instead of clicking Confirm". Nothing here should ever grow a prose-to-consent
54
+ * mapping.
55
+ *
56
+ * Double-underscored, and named here rather than at a tool, for the same reason as
57
+ * {@link SURFACE_EXCHANGE_ID_PARAM}: several parties must agree on the spelling.
58
+ */
59
+ export declare const SURFACE_TYPED_ANSWER_PARAM = "__typedAnswer";
60
+ /**
61
+ * Which inbound route may answer an exchange. `"mcp-ui"` (the host's default) is a tool-call-shaped answer
62
+ * posted to the MCP-UI route; `"a2ui"` is a renderer message posted to the A2UI route. A2UI posts
63
+ * carry no `toolId`, so without this an A2UI message could land in, and cancel, a pending MCP-UI
64
+ * confirmation of the same principal.
65
+ */
66
+ export type SurfaceAnswerChannel = string;
67
+ /** Who an exchange belongs to. Checked on every delivery; a mismatch is a rejection, not a warning. */
68
+ export interface SurfaceExchangeBinding {
69
+ /** The tool that opened it. A message for one tool cannot resolve another's exchange. */
70
+ readonly toolId: string;
71
+ /** The principal whose run opened it: one human's answer must not land in another's call. */
72
+ readonly principalId: string;
73
+ /** The route allowed to answer it; omission uses the store's injected default channel. */
74
+ readonly channel?: SurfaceAnswerChannel;
75
+ }
76
+ /**
77
+ * One inbound message, or the reason there will not be one.
78
+ *
79
+ * The non-`received` cases are part of the contract rather than error handling (ADR-055 Decision 6):
80
+ * blocking moves the timeout story, it does not remove it, and a handler must be able to tell the
81
+ * model something true about which happened.
82
+ */
83
+ export type SurfaceMessage = {
84
+ status: 'received';
85
+ params: Record<string, unknown>;
86
+ } | {
87
+ status: 'expired';
88
+ } | {
89
+ status: 'abandoned';
90
+ };
91
+ /** Why a delivery did not reach an exchange. Reported to the browser, never to the model. */
92
+ export type SurfaceDeliveryRejectionReason = 'unknown-or-closed' | 'binding-mismatch';
93
+ export type DeliverResult = {
94
+ ok: true;
95
+ } | {
96
+ ok: false;
97
+ reason: SurfaceDeliveryRejectionReason;
98
+ };
99
+ /** A live, two-way conversation bound to one in-flight tool call. */
100
+ export interface SurfaceExchange {
101
+ /** Correlation handle embedded in the human's answer carrier; not a secret. */
102
+ readonly id: string;
103
+ /**
104
+ * Sends one message out to the human.
105
+ *
106
+ * @throws {Error} If the exchange has already ended — the same posture the daemon's emitter takes,
107
+ * because a message sent onto a settled call has no way to be answered.
108
+ */
109
+ send(args: {
110
+ emission: SurfaceEmission;
111
+ }, optional?: Record<string, never>): Promise<void>;
112
+ /**
113
+ * Waits for the next inbound message.
114
+ *
115
+ * Returns a buffered one immediately if the human answered while the handler was busy. Once the
116
+ * exchange has ended, returns that terminal status rather than hanging — so a handler looping on
117
+ * `receive()` terminates instead of waiting out the transport.
118
+ */
119
+ receive(_args: Record<string, never>, optional?: Record<string, never>): Promise<SurfaceMessage>;
120
+ /** Ends the exchange idempotently. Pending receives resolve "abandoned"; an ended exchange is unchanged. */
121
+ close(_args: Record<string, never>, optional?: Record<string, never>): void;
122
+ /**
123
+ * When the exchange expires if nothing more happens, in epoch ms: the earlier of the idle and
124
+ * lifetime deadlines. Activity moves the idle one, so read it right before rendering a deadline
125
+ * for the human; a card drawn just before its `send()` shows a deadline a few ms early, never late.
126
+ */
127
+ expiresAtMs(): number;
128
+ }
129
+ /** Messaging helpers do not require deadline tracking. Hosts may supply a conversation port
130
+ * without a deadline; the daemon store always returns the full, dated SurfaceExchange. */
131
+ export type SurfaceExchangeConversation = Omit<SurfaceExchange, 'expiresAtMs'> & Partial<Pick<SurfaceExchange, 'expiresAtMs'>>;
132
+ /**
133
+ * What a delivery must prove about itself. `principalId` is load-bearing for every channel — one
134
+ * human's answer must never land in another's call. `toolId` is load-bearing only for a channel
135
+ * whose correlation carrier is itself a tool call: MCP-UI's surface can only answer by issuing one,
136
+ * so it always has a real tool name to offer, and the store checks it.
137
+ *
138
+ * A channel that correlates directly by its own id — A2UI's `surfaceId`, the run protocol's own
139
+ * `surface_request`/`surface_response` — has no natural `toolId` to supply; the browser never
140
+ * learns which tool opened the exchange, only its id. Requiring one anyway would force such a
141
+ * channel to either lie (send a placeholder that means nothing) or grow a lookup this store does
142
+ * not expose. So `toolId` is optional here: when a caller supplies it, it must still match exactly
143
+ * (MCP-UI's behavior is unchanged, byte for byte); when omitted, only `principalId` and the
144
+ * exchange's own unguessable `randomUUID()` id gate the delivery. That pair is still sufficient —
145
+ * the id is not enumerable, and `principalId` is a server-verified session identity, never a value
146
+ * the browser chooses (see `run-ownership.ts`) — so relaxing the caller's OBLIGATION does not
147
+ * relax the exchange's own binding, which `open()` still records honestly as whatever tool actually
148
+ * opened it.
149
+ */
150
+ export interface SurfaceDeliverySpec {
151
+ exchangeId: string;
152
+ params: Record<string, unknown>;
153
+ principalId: string;
154
+ toolId?: string;
155
+ channel?: SurfaceAnswerChannel;
156
+ }
157
+ export interface SurfaceExchangeStore {
158
+ /**
159
+ * Opens an exchange bound to one in-flight tool call.
160
+ *
161
+ * @param args.emit - The call's live {@link SurfaceEmitter}. Required, not optional: an exchange whose
162
+ * messages cannot reach anybody is a guaranteed deadlock, and taking the emitter here is what makes
163
+ * that state unrepresentable rather than merely discouraged.
164
+ */
165
+ open(args: {
166
+ binding: SurfaceExchangeBinding;
167
+ emit: SurfaceEmitter;
168
+ }, optional?: Record<string, never>): SurfaceExchange;
169
+ /**
170
+ * Routes one inbound message to the exchange it names, queueing it if nothing is waiting yet.
171
+ * See {@link SurfaceDeliverySpec} for why `toolId` is optional rather than part of
172
+ * {@link SurfaceExchangeBinding} here.
173
+ */
174
+ deliver(requiredArgs: Pick<SurfaceDeliverySpec, 'exchangeId' | 'params' | 'principalId'>, optionalArgs?: Pick<SurfaceDeliverySpec, 'toolId' | 'channel'>): DeliverResult;
175
+ /**
176
+ * Resolves which open exchange a TYPED answer is for, when the human named none.
177
+ *
178
+ * A person typing into the chat composer does not know exchanges exist, so they cannot carry a
179
+ * {@link SURFACE_EXCHANGE_ID_PARAM} the way a rendered form does. This store is the only thing
180
+ * that knows what is open, so the correlation is recovered here rather than guessed by a client
181
+ * scraping an id out of the surface's own (model-influenced) HTML.
182
+ *
183
+ * Scoped by `toolId` as well as `principalId` on purpose: it is an OPT-IN, not a broadcast. Only
184
+ * a tool that has a safe reading of prose should ever be named here, which is what keeps typed
185
+ * text away from confirmation-shaped exchanges (see {@link SURFACE_TYPED_ANSWER_PARAM}).
186
+ *
187
+ * @returns The single matching open exchange's id, or `undefined` when there is none — or more
188
+ * than one. Ambiguity fails closed: delivering to the wrong one would silently answer a question
189
+ * the human was not looking at, and report their words as an answer to it.
190
+ * @complexity O(n) in the number of open exchanges, which is bounded by concurrent agent runs.
191
+ */
192
+ findTypedAnswerTarget(args: {
193
+ principalId: string;
194
+ toolId: string;
195
+ }, optional?: Record<string, never>): string | undefined;
196
+ /** Open, unsettled count — for tests and diagnostics only. */
197
+ size(): number;
198
+ }
199
+ /** Send a form, handle its answer, report the outcome, and finally close the exchange.
200
+ * Supply `askThenReport` from `@jini-ai/daemon/surface-exchanges` at the host boundary. */
201
+ export type SurfaceAskThenReport = <Result>(args: {
202
+ exchange: SurfaceExchangeConversation;
203
+ confirmationEmission: SurfaceEmission;
204
+ handle: (answer: SurfaceMessage) => Promise<{
205
+ result: Result;
206
+ outcome?: SurfaceEmission;
207
+ }>;
208
+ }, optional?: Record<string, never>) => Promise<Result>;
209
+ //# sourceMappingURL=surface-exchanges.d.ts.map
@@ -0,0 +1,55 @@
1
+ /**
2
+ * The callback param carrying an exchange id from a rendered surface back to the server.
3
+ *
4
+ * Double-underscored to keep it clear of a surface's real fields, and named in one place because
5
+ * three parties must agree on it: whatever builds the surface, the route that routes on it, and the
6
+ * handler that opened the exchange.
7
+ *
8
+ * This is MCP-UI's carrier specifically, because an mcp-ui surface can only answer by issuing a tool
9
+ * call, so its correlation has to ride inside that call's params. A channel that can name the
10
+ * exchange directly should do so — `mcp-ui-tool-calls-route.ts` also accepts a top-level
11
+ * `exchangeId`, and A2UI/`surface_request` correlate by their own `surfaceId`.
12
+ */
13
+ export const SURFACE_EXCHANGE_ID_PARAM = '__exchangeId';
14
+ /**
15
+ * The callback param a surface's Cancel action sets, so a dismissal resolves the exchange
16
+ * immediately instead of stranding the agent until the idle deadline.
17
+ *
18
+ * Carried as data rather than as a distinct {@link SurfaceMessage} status: "the human answered, and
19
+ * the answer was no" is genuinely an answer, and only the tool that built the surface knows what a
20
+ * dismissal means for its own result.
21
+ */
22
+ export const SURFACE_DISMISSED_PARAM = '__dismissed';
23
+ /**
24
+ * The callback param carrying free text a human TYPED — into the chat composer — as their answer to
25
+ * an outstanding surface, rather than clicking the rendered form.
26
+ *
27
+ * ## The deadlock this exists to break
28
+ *
29
+ * A held-open exchange can only be resolved by a message that names it, and until now every param
30
+ * shape a surface posts back was one the FORM produced. A human who reads the question, ignores the
31
+ * dialog and types the answer into the composer therefore resolved nothing: their message was queued
32
+ * behind the very run it was meant to unblock, and the agent sat parked until the idle deadline
33
+ * fired minutes later.
34
+ *
35
+ * ## Why it is a separate param rather than the tool's own answer fields
36
+ *
37
+ * Prose is not a selection. A tool whose answer fields are option VALUES the model itself supplied
38
+ * (`assistant_ask_choice`'s `choice`/`selections`) would, if prose were poured into them, report to
39
+ * the model that the administrator picked an option that was never offered. Carrying typed text
40
+ * under its own name is what forces each tool to decide, explicitly, what a typed answer means for
41
+ * its own result — and lets a tool that has no safe reading of one simply ignore it.
42
+ *
43
+ * ## What it deliberately cannot do
44
+ *
45
+ * It cannot confirm anything. `classifyConfirmationAnswer` reads only a literal `decision:
46
+ * "confirm"`, so a typed answer arriving at a confirmation-shaped exchange fails closed to
47
+ * `"declined"` with no code change and no exception — which is the correct reading of "the human
48
+ * wrote a sentence instead of clicking Confirm". Nothing here should ever grow a prose-to-consent
49
+ * mapping.
50
+ *
51
+ * Double-underscored, and named here rather than at a tool, for the same reason as
52
+ * {@link SURFACE_EXCHANGE_ID_PARAM}: several parties must agree on the spelling.
53
+ */
54
+ export const SURFACE_TYPED_ANSWER_PARAM = '__typedAnswer';
55
+ //# sourceMappingURL=surface-exchanges.js.map
@@ -0,0 +1,71 @@
1
+ /** Domain registrations own these declarations; projections never infer risk or authorization. */
2
+ export interface ToolApprovalMetadata {
3
+ readonly class: 'read' | 'edit' | 'trash' | 'delete' | 'restore-over-existing' | 'publish' | 'replace-secret' | 'escalation';
4
+ readonly confirmation: 'direct' | 'policy' | 'plan';
5
+ readonly input?: 'human-form';
6
+ /** Host-owned input classification rule, separate from the declared side effects. */
7
+ readonly rule?: string;
8
+ }
9
+ export interface ToolMetadata<A extends ToolApprovalMetadata = ToolApprovalMetadata> {
10
+ readonly search?: {
11
+ readonly keywords?: string;
12
+ readonly queries?: readonly string[];
13
+ };
14
+ readonly approval?: A;
15
+ readonly mcpUi?: {
16
+ readonly redeemable?: boolean;
17
+ readonly exchangeOnly?: boolean;
18
+ readonly secretField?: {
19
+ readonly secret: true;
20
+ };
21
+ };
22
+ }
23
+ export type ToolMetadataById<A extends ToolApprovalMetadata = ToolApprovalMetadata> = Readonly<Record<string, ToolMetadata<A>>>;
24
+ /** Own-property lookup: inherited object keys can never declare a tool's approval or callback access. */
25
+ export declare function toolMetadataFor({ toolId, metadata }: {
26
+ toolId: string;
27
+ metadata: ToolMetadataById | undefined;
28
+ }, _optional?: Record<string, never>): ToolMetadata | undefined;
29
+ /**
30
+ * Bind host-owned metadata to registrations returned by a reusable domain package. Existing
31
+ * catalog metadata wins; handlers, authorization and independently validated risk are preserved.
32
+ * @returns New registrations for declared ids; undeclared registrations retain their identity.
33
+ * @complexity O(t) time and space in registrations.
34
+ * @example withToolMetadata({ registrations, metadata: notesMetadata });
35
+ */
36
+ export declare function withToolMetadata({ registrations, metadata }: {
37
+ registrations: readonly import('./tool-registry.js').ToolRegistration[];
38
+ metadata: ToolMetadataById;
39
+ }, _optional?: Record<string, never>): import('./tool-registry.js').ToolRegistration[];
40
+ /** Immutable domain declarations projected for discovery and human callback gates. */
41
+ export interface ToolMetadataProjections<A extends ToolApprovalMetadata = ToolApprovalMetadata> {
42
+ readonly byId: ToolMetadataById<A>;
43
+ readonly keywords: Readonly<Record<string, string>>;
44
+ readonly queries: Readonly<Record<string, readonly string[]>>;
45
+ readonly approvals: Readonly<Record<string, A>>;
46
+ readonly policyConfirmationIds: ReadonlySet<string>;
47
+ readonly secretFormIds: ReadonlySet<string>;
48
+ readonly secretForms: Readonly<Record<string, {
49
+ readonly secretField: {
50
+ readonly secret: true;
51
+ };
52
+ }>>;
53
+ readonly redeemableIds: ReadonlySet<string>;
54
+ readonly exchangeOnlyIds: ReadonlySet<string>;
55
+ }
56
+ /**
57
+ * Generate projections from the same metadata domain registration consumes. Duplicate ownership
58
+ * fails before any projection is returned. No handler, risk classification or permission is inferred.
59
+ * @param required.domains Domain names and their independently authored declarations.
60
+ * @returns Fresh maps and sets; inputs are never mutated.
61
+ * @throws Error naming both domains claiming the same id.
62
+ * @complexity O(t) time and space in declared tools, performed at composition time.
63
+ * @example projectToolMetadata({ domains: [{ domain: 'notes', tools: notesMetadata }] });
64
+ */
65
+ export declare function projectToolMetadata<A extends ToolApprovalMetadata>({ domains }: {
66
+ domains: readonly {
67
+ readonly domain: string;
68
+ readonly tools: ToolMetadataById<A>;
69
+ }[];
70
+ }, _optional?: Record<string, never>): ToolMetadataProjections<A>;
71
+ //# sourceMappingURL=tool-metadata.d.ts.map
@@ -0,0 +1,56 @@
1
+ /** Own-property lookup: inherited object keys can never declare a tool's approval or callback access. */
2
+ export function toolMetadataFor({ toolId, metadata }, _optional = {}) {
3
+ return metadata && Object.hasOwn(metadata, toolId) ? metadata[toolId] : undefined;
4
+ }
5
+ /**
6
+ * Bind host-owned metadata to registrations returned by a reusable domain package. Existing
7
+ * catalog metadata wins; handlers, authorization and independently validated risk are preserved.
8
+ * @returns New registrations for declared ids; undeclared registrations retain their identity.
9
+ * @complexity O(t) time and space in registrations.
10
+ * @example withToolMetadata({ registrations, metadata: notesMetadata });
11
+ */
12
+ export function withToolMetadata({ registrations, metadata }, _optional = {}) {
13
+ return registrations.map(registration => {
14
+ const declared = toolMetadataFor({ toolId: registration.descriptor.id, metadata });
15
+ if (!declared || registration.descriptor.metadata)
16
+ return registration;
17
+ return { ...registration, descriptor: { ...registration.descriptor, metadata: declared } };
18
+ });
19
+ }
20
+ /**
21
+ * Generate projections from the same metadata domain registration consumes. Duplicate ownership
22
+ * fails before any projection is returned. No handler, risk classification or permission is inferred.
23
+ * @param required.domains Domain names and their independently authored declarations.
24
+ * @returns Fresh maps and sets; inputs are never mutated.
25
+ * @throws Error naming both domains claiming the same id.
26
+ * @complexity O(t) time and space in declared tools, performed at composition time.
27
+ * @example projectToolMetadata({ domains: [{ domain: 'notes', tools: notesMetadata }] });
28
+ */
29
+ export function projectToolMetadata({ domains }, _optional = {}) {
30
+ const byId = Object.create(null);
31
+ const owners = new Map();
32
+ for (const { domain, tools } of domains) {
33
+ for (const [id, metadata] of Object.entries(tools)) {
34
+ const owner = owners.get(id);
35
+ if (owner !== undefined)
36
+ throw new Error(`tool-metadata: '${id}' is declared by both ${owner} and ${domain}`);
37
+ owners.set(id, domain);
38
+ byId[id] = metadata;
39
+ }
40
+ }
41
+ const entries = Object.entries(byId);
42
+ const approvals = Object.fromEntries(entries.filter(([, metadata]) => metadata.approval !== undefined).map(([id, metadata]) => [id, metadata.approval]));
43
+ const policyConfirmationIds = new Set(Object.entries(approvals).filter(([, policy]) => policy.confirmation === 'policy' || policy.confirmation === 'plan' || !!policy.rule).map(([id]) => id));
44
+ const secretEntries = entries.filter(([, metadata]) => metadata.mcpUi?.secretField?.secret === true);
45
+ const secretFormIds = new Set(secretEntries.map(([id]) => id));
46
+ return {
47
+ byId,
48
+ keywords: Object.fromEntries(entries.filter(([, metadata]) => metadata.search?.keywords !== undefined).map(([id, metadata]) => [id, metadata.search.keywords])),
49
+ queries: Object.fromEntries(entries.filter(([, metadata]) => metadata.search?.queries !== undefined).map(([id, metadata]) => [id, metadata.search.queries])),
50
+ approvals, policyConfirmationIds, secretFormIds,
51
+ secretForms: Object.fromEntries(secretEntries.map(([id, metadata]) => [id, { secretField: metadata.mcpUi.secretField }])),
52
+ redeemableIds: new Set([...policyConfirmationIds, ...secretFormIds, ...entries.filter(([, metadata]) => metadata.mcpUi?.redeemable === true).map(([id]) => id)]),
53
+ exchangeOnlyIds: new Set([...policyConfirmationIds, ...secretFormIds, ...entries.filter(([, metadata]) => metadata.mcpUi?.exchangeOnly === true).map(([id]) => id)]),
54
+ };
55
+ }
56
+ //# sourceMappingURL=tool-metadata.js.map
@@ -0,0 +1,76 @@
1
+ /** Shared permission checking preserves the command/tool denial contract without loading a domain. */
2
+ export interface AuthorizationRequired {
3
+ principalId: string;
4
+ permission: string;
5
+ workspaceId: string;
6
+ }
7
+ export interface AuthorizationOptional {
8
+ entityType?: string | undefined;
9
+ entityId?: string | undefined;
10
+ }
11
+ export type AuthorizationPort = (required: AuthorizationRequired, optional?: AuthorizationOptional) => Promise<{
12
+ allowed: boolean;
13
+ reason: string;
14
+ }>;
15
+ export type LegacyToolAuthorizeFn = (required: AuthorizationRequired & AuthorizationOptional) => Promise<{
16
+ allowed: boolean;
17
+ reason: string;
18
+ }>;
19
+ /** Permission denial shared with the CMS command compatibility export.
20
+ * Keeps the original class name, message and permission/reason fields; no domain imports.
21
+ * @example new ForbiddenError({ message: "denied", permission: "settings.read", reason: "no_grant" }, {});
22
+ */
23
+ export declare class ForbiddenError extends Error {
24
+ readonly permission: string;
25
+ readonly reason: string;
26
+ constructor({ message, permission, reason }: {
27
+ message: string;
28
+ permission: string;
29
+ reason: string;
30
+ }, _optional?: Record<string, never>);
31
+ }
32
+ export interface RequireToolPermissionRequired {
33
+ authorize: AuthorizationPort;
34
+ workspaceId: string;
35
+ principalId: string;
36
+ permission: string;
37
+ }
38
+ export interface RequireToolPermissionOptional {
39
+ entityType?: string | undefined;
40
+ entityId?: string | undefined;
41
+ }
42
+ /**
43
+ * Runs the identical inline `authorize()` check the corresponding admin HTTP route performs, for a
44
+ * tool whose own domain function carries no `authorize()` call to inherit.
45
+ *
46
+ * This is NOT a second evaluator alongside another check — that is exactly the single-evaluator
47
+ * rule's concern. For each tool that
48
+ * calls it, this IS the only gate that tool's execution ever reaches, reached by an identical path
49
+ * to the one a human clicking the same admin route reaches. Domains split cleanly on which kind
50
+ * they are: content-types/Forms/Identity mutations and every Widgets write-service call self-enforce
51
+ * inside the domain function and must NOT call this; Comments/Members/Newsletter/Media/Menus/
52
+ * Database/Recovery gate in the route instead, so their handlers call this in the route's place.
53
+ * Which kind a given tool is stays documented next to that tool's handler, not here.
54
+ *
55
+ * Throws `core/commands`'s `ForbiddenError` — the same class `executeCommand` itself throws — so a
56
+ * tool caller and a route caller see the identical error shape for an identical denial. Throw
57
+ * rather than a 403 body because the caller here is `ToolExecutor`, which reads a thrown error as a
58
+ * failed execution and has no response to write to.
59
+ *
60
+ * The defining command module re-exports this class: its barrel also exports mutation appliers
61
+ * that name content features. Pulling that barrel into shared permission wiring would couple
62
+ * every tool domain to those features; kernel ownership preserves the identical denial class
63
+ * without that dependency closure.
64
+ *
65
+ * @param required - The `authorize()` evaluator and the workspace the run is scoped to.
66
+ * @param optional - Entity scope for who is asking, for what permission, optionally against which entity.
67
+ * @throws {ForbiddenError} If `authorize()` denies.
68
+ * @complexity O(1) beyond the injected `authorize()` call.
69
+ * @overallScore 100
70
+ */
71
+ export declare function requireToolPermission(required: RequireToolPermissionRequired, optional?: RequireToolPermissionOptional): Promise<void>;
72
+ /** Adapt an existing evaluator without losing entity scope. @complexity O(1) plus evaluation. */
73
+ export declare function adaptLegacyAuthorize(required: {
74
+ authorize: LegacyToolAuthorizeFn;
75
+ }, _optional?: Record<string, never>): AuthorizationPort;
76
+ //# sourceMappingURL=tool-permissions.d.ts.map
@@ -0,0 +1,57 @@
1
+ /** Permission denial shared with the CMS command compatibility export.
2
+ * Keeps the original class name, message and permission/reason fields; no domain imports.
3
+ * @example new ForbiddenError({ message: "denied", permission: "settings.read", reason: "no_grant" }, {});
4
+ */
5
+ export class ForbiddenError extends Error {
6
+ permission;
7
+ reason;
8
+ constructor({ message, permission, reason }, _optional = {}) {
9
+ super(message);
10
+ this.permission = permission;
11
+ this.reason = reason;
12
+ }
13
+ }
14
+ /**
15
+ * Runs the identical inline `authorize()` check the corresponding admin HTTP route performs, for a
16
+ * tool whose own domain function carries no `authorize()` call to inherit.
17
+ *
18
+ * This is NOT a second evaluator alongside another check — that is exactly the single-evaluator
19
+ * rule's concern. For each tool that
20
+ * calls it, this IS the only gate that tool's execution ever reaches, reached by an identical path
21
+ * to the one a human clicking the same admin route reaches. Domains split cleanly on which kind
22
+ * they are: content-types/Forms/Identity mutations and every Widgets write-service call self-enforce
23
+ * inside the domain function and must NOT call this; Comments/Members/Newsletter/Media/Menus/
24
+ * Database/Recovery gate in the route instead, so their handlers call this in the route's place.
25
+ * Which kind a given tool is stays documented next to that tool's handler, not here.
26
+ *
27
+ * Throws `core/commands`'s `ForbiddenError` — the same class `executeCommand` itself throws — so a
28
+ * tool caller and a route caller see the identical error shape for an identical denial. Throw
29
+ * rather than a 403 body because the caller here is `ToolExecutor`, which reads a thrown error as a
30
+ * failed execution and has no response to write to.
31
+ *
32
+ * The defining command module re-exports this class: its barrel also exports mutation appliers
33
+ * that name content features. Pulling that barrel into shared permission wiring would couple
34
+ * every tool domain to those features; kernel ownership preserves the identical denial class
35
+ * without that dependency closure.
36
+ *
37
+ * @param required - The `authorize()` evaluator and the workspace the run is scoped to.
38
+ * @param optional - Entity scope for who is asking, for what permission, optionally against which entity.
39
+ * @throws {ForbiddenError} If `authorize()` denies.
40
+ * @complexity O(1) beyond the injected `authorize()` call.
41
+ * @overallScore 100
42
+ */
43
+ export async function requireToolPermission(required, optional = {}) {
44
+ const result = await required.authorize({
45
+ principalId: required.principalId, permission: required.permission, workspaceId: required.workspaceId,
46
+ }, optional);
47
+ if (!result.allowed)
48
+ throw new ForbiddenError({
49
+ message: `principal '${required.principalId}' is not authorized for '${required.permission}' (${result.reason})`,
50
+ permission: required.permission, reason: result.reason,
51
+ });
52
+ }
53
+ /** Adapt an existing evaluator without losing entity scope. @complexity O(1) plus evaluation. */
54
+ export function adaptLegacyAuthorize(required, _optional = {}) {
55
+ return (request, optional = {}) => required.authorize({ ...request, ...optional });
56
+ }
57
+ //# sourceMappingURL=tool-permissions.js.map
@@ -26,6 +26,7 @@
26
26
  * `archived provenance ledger` for the 2026-07-29 hardening note.
27
27
  */
28
28
  import type { Principal } from './principal.js';
29
+ import type { ToolMetadata } from './tool-metadata.js';
29
30
  /** A structural run reference — anything with a stable `id` satisfies this (e.g. `@jini-ai/protocol`'s `RunStatus`). No import needed to satisfy it; see the module doc. */
30
31
  export interface RunRef {
31
32
  readonly id: string;
@@ -35,6 +36,8 @@ export interface RunRef {
35
36
  * handler or policy — those are only reachable via {@link authorizeToolInvocation}.
36
37
  */
37
38
  export interface ToolDescriptor {
39
+ /** Domain-owned discovery and approval metadata; grants no execution authority. */
40
+ readonly metadata?: ToolMetadata;
38
41
  readonly id: string;
39
42
  readonly description?: string;
40
43
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jini-ai/core",
3
- "version": "0.4.1",
3
+ "version": "0.4.2",
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": {
@@ -20,7 +20,44 @@
20
20
  "./contribution-registry": "universal",
21
21
  "./naming": "universal",
22
22
  "./primitives": "universal",
23
- "./text": "universal"
23
+ "./text": "universal",
24
+ "./settings": "universal",
25
+ "./settings/express": "node"
26
+ },
27
+ "isolation": {
28
+ "entries": {
29
+ "./primitives": "core",
30
+ "./settings": "settings",
31
+ "./settings/express": "settings"
32
+ },
33
+ "domains": {
34
+ "settings": [
35
+ "src/settings"
36
+ ]
37
+ },
38
+ "shared": [
39
+ "src/tool-permissions.ts"
40
+ ],
41
+ "forbidden": {
42
+ "universal": [
43
+ "node:",
44
+ "express",
45
+ "react",
46
+ "@jini-ai/cms"
47
+ ],
48
+ ".": [
49
+ "node:",
50
+ "express",
51
+ "react",
52
+ "@jini-ai/cms",
53
+ "@jini-ai/core/settings",
54
+ "./settings",
55
+ "../settings"
56
+ ],
57
+ "./settings/express": [
58
+ "@jini-ai/cms"
59
+ ]
60
+ }
24
61
  }
25
62
  },
26
63
  "type": "module",
@@ -67,6 +104,16 @@
67
104
  "types": "./dist/text/index.d.ts",
68
105
  "import": "./dist/text/index.js",
69
106
  "default": "./dist/text/index.js"
107
+ },
108
+ "./settings": {
109
+ "types": "./dist/settings/index.d.ts",
110
+ "import": "./dist/settings/index.js",
111
+ "default": "./dist/settings/index.js"
112
+ },
113
+ "./settings/express": {
114
+ "types": "./dist/settings/express/index.d.ts",
115
+ "import": "./dist/settings/express/index.js",
116
+ "default": "./dist/settings/express/index.js"
70
117
  }
71
118
  },
72
119
  "files": [
@@ -87,7 +134,17 @@
87
134
  "registry": "https://registry.npmjs.org"
88
135
  },
89
136
  "devDependencies": {
90
- "@vitest/coverage-v8": "^2.1.9"
137
+ "@vitest/coverage-v8": "^2.1.9",
138
+ "express": "^4.21.0",
139
+ "@types/express": "^4.17.21"
140
+ },
141
+ "peerDependencies": {
142
+ "express": "^4.21.0"
143
+ },
144
+ "peerDependenciesMeta": {
145
+ "express": {
146
+ "optional": true
147
+ }
91
148
  },
92
149
  "scripts": {
93
150
  "build": "tsc -p tsconfig.json",