@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.
- package/CHANGELOG.md +11 -0
- package/dist/agent-tools.d.ts +1 -1
- package/dist/approval.d.ts +15 -0
- package/dist/approval.js +87 -22
- package/dist/batch-tool-inputs.d.ts +147 -0
- package/dist/batch-tool-inputs.js +257 -0
- package/dist/feature-flags.d.ts +21 -0
- package/dist/feature-flags.js +17 -0
- package/dist/fetch/index.d.ts +2 -1
- package/dist/fetch/index.js +1 -0
- package/dist/fetch/loader-read.d.ts +16 -0
- package/dist/fetch/loader-read.js +22 -0
- package/dist/fetch/types.d.ts +12 -2
- package/dist/gated-mutations/gateway.js +32 -27
- package/dist/index.d.ts +9 -1
- package/dist/index.js +8 -1
- package/dist/object-store.d.ts +115 -0
- package/dist/object-store.js +70 -0
- package/dist/origin-validation.js +64 -57
- package/dist/primitives/http.d.ts +2 -2
- package/dist/primitives/index.d.ts +1 -0
- package/dist/primitives/index.js +1 -0
- package/dist/primitives/paths.js +17 -10
- package/dist/primitives/tasks.d.ts +10 -0
- package/dist/primitives/tasks.js +8 -0
- package/dist/provider-catalog.d.ts +76 -0
- package/dist/provider-catalog.js +6 -0
- package/dist/provider-registry.d.ts +144 -0
- package/dist/provider-registry.js +138 -0
- package/dist/records.d.ts +24 -0
- package/dist/records.js +25 -0
- package/dist/redact.d.ts +4 -0
- package/dist/redact.js +57 -34
- package/dist/registration-kit.d.ts +7 -4
- package/dist/registration-kit.js +81 -62
- package/dist/settings/agent-value-write-tools.d.ts +19 -0
- package/dist/settings/agent-value-write-tools.js +131 -70
- package/dist/settings/ensure-definitions.d.ts +4 -2
- package/dist/settings/ensure-definitions.js +79 -63
- package/dist/settings/express/cms-adapter.d.ts +2 -2
- package/dist/settings/express/cms-adapter.js +34 -22
- package/dist/settings/express/http-request.d.ts +33 -0
- package/dist/settings/express/http-request.js +49 -0
- package/dist/settings/express/http.d.ts +2 -2
- package/dist/settings/express/http.js +16 -39
- package/dist/settings/express/stream.d.ts +2 -2
- package/dist/settings/express/stream.js +49 -27
- package/dist/settings/repo.memory.js +7 -4
- package/dist/settings/settings.js +54 -60
- package/dist/settings/tool-registrations.d.ts +2 -3
- package/dist/settings/tool-registrations.js +64 -39
- package/dist/settings/write-service.d.ts +19 -0
- package/dist/settings/write-service.js +34 -0
- package/dist/target-scope.d.ts +52 -0
- package/dist/target-scope.js +82 -0
- package/dist/text/escape.js +2 -2
- package/dist/text/sanitize-untrusted-text.d.ts +5 -0
- package/dist/text/sanitize-untrusted-text.js +55 -35
- package/dist/tool-exposure.d.ts +79 -0
- package/dist/tool-exposure.js +163 -0
- package/dist/tool-input-errors.d.ts +23 -0
- package/dist/tool-input-errors.js +24 -0
- package/dist/tool-input-normalization.d.ts +37 -0
- package/dist/tool-input-normalization.js +188 -0
- package/dist/tool-metadata-types.d.ts +26 -0
- package/dist/tool-metadata-types.js +2 -0
- package/dist/tool-metadata.d.ts +2 -23
- package/dist/tool-registry.d.ts +38 -22
- package/dist/tool-registry.js +35 -20
- package/package.json +20 -2
- package/plugin-directory.js +72 -33
|
@@ -0,0 +1,26 @@
|
|
|
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
|
+
/** Installed guidance capabilities needed by a writer; independent of permission and risk. */
|
|
11
|
+
readonly guidanceCapabilities?: readonly string[];
|
|
12
|
+
readonly search?: {
|
|
13
|
+
readonly keywords?: string;
|
|
14
|
+
readonly queries?: readonly string[];
|
|
15
|
+
};
|
|
16
|
+
readonly approval?: A;
|
|
17
|
+
readonly mcpUi?: {
|
|
18
|
+
readonly redeemable?: boolean;
|
|
19
|
+
readonly exchangeOnly?: boolean;
|
|
20
|
+
readonly secretField?: {
|
|
21
|
+
readonly secret: true;
|
|
22
|
+
};
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
export type ToolMetadataById<A extends ToolApprovalMetadata = ToolApprovalMetadata> = Readonly<Record<string, ToolMetadata<A>>>;
|
|
26
|
+
//# sourceMappingURL=tool-metadata-types.d.ts.map
|
package/dist/tool-metadata.d.ts
CHANGED
|
@@ -1,26 +1,5 @@
|
|
|
1
|
-
|
|
2
|
-
export
|
|
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>>>;
|
|
1
|
+
import type { ToolApprovalMetadata, ToolMetadata, ToolMetadataById } from './tool-metadata-types.js';
|
|
2
|
+
export type { ToolApprovalMetadata, ToolMetadata, ToolMetadataById } from './tool-metadata-types.js';
|
|
24
3
|
/** Own-property lookup: inherited object keys can never declare a tool's approval or callback access. */
|
|
25
4
|
export declare function toolMetadataFor({ toolId, metadata }: {
|
|
26
5
|
toolId: string;
|
package/dist/tool-registry.d.ts
CHANGED
|
@@ -26,7 +26,9 @@
|
|
|
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 {
|
|
29
|
+
import type { AgentToolSideEffect } from './agent-tools.js';
|
|
30
|
+
import type { ToolMetadata } from './tool-metadata-types.js';
|
|
31
|
+
import { type ToolInputNormalizer } from './tool-input-normalization.js';
|
|
30
32
|
/** 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. */
|
|
31
33
|
export interface RunRef {
|
|
32
34
|
readonly id: string;
|
|
@@ -42,8 +44,8 @@ export interface ToolDescriptor {
|
|
|
42
44
|
readonly description?: string;
|
|
43
45
|
/**
|
|
44
46
|
* JSON-Schema-shaped description of this tool's accepted input, carried as `unknown` because the
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
+
* gate reads common property/type fields for input normalization but leaves validation and
|
|
48
|
+
* schema dialects to the handler; no validator dependency belongs in every registry consumer.
|
|
47
49
|
*
|
|
48
50
|
* It exists because *every* discovery mechanism needs it and nothing else in the kernel can
|
|
49
51
|
* supply it: a caller that can enumerate `ToolRegistry.list({})` still cannot tell an agent what
|
|
@@ -74,6 +76,8 @@ export interface ToolDescriptor {
|
|
|
74
76
|
* would admit every unclassified write, which is the exact failure the flag exists to prevent.
|
|
75
77
|
*/
|
|
76
78
|
readonly readOnly?: boolean;
|
|
79
|
+
/** Independently checked domain risk, retained for discovery; never grants permission. */
|
|
80
|
+
readonly sideEffects?: AgentToolSideEffect;
|
|
77
81
|
}
|
|
78
82
|
/**
|
|
79
83
|
* The single read-only determination: may this tool run through a surface that has promised its
|
|
@@ -90,6 +94,17 @@ export interface ToolDescriptor {
|
|
|
90
94
|
export declare function isReadOnlyTool({ descriptor }: {
|
|
91
95
|
descriptor: ToolDescriptor | undefined;
|
|
92
96
|
}): boolean;
|
|
97
|
+
/** Project registration risk for discovery using the same read-only decision as execution.
|
|
98
|
+
* Missing classification stays unknown; discovery never grants execution authority.
|
|
99
|
+
* @returns Explicit readOnly and sideEffects fields for a registry descriptor.
|
|
100
|
+
* @complexity O(1); no effects.
|
|
101
|
+
*/
|
|
102
|
+
export declare function toolDiscoveryMetadata({ descriptor }: {
|
|
103
|
+
descriptor: Pick<ToolDescriptor, 'id' | 'readOnly' | 'sideEffects'> | undefined;
|
|
104
|
+
}, _options?: {}): {
|
|
105
|
+
readOnly: boolean;
|
|
106
|
+
sideEffects: AgentToolSideEffect | 'unknown';
|
|
107
|
+
};
|
|
93
108
|
/**
|
|
94
109
|
* One human-facing message a handler pushes out mid-call.
|
|
95
110
|
*
|
|
@@ -145,27 +160,21 @@ export interface ToolExecutionContext {
|
|
|
145
160
|
/** Optional handler ports supplied by the invoking transport. */
|
|
146
161
|
export interface ToolExecutionOptions {
|
|
147
162
|
readonly emitSurface?: SurfaceEmitter;
|
|
163
|
+
/** Invoke through the same executor, retaining this call's principal, run, cancellation and
|
|
164
|
+
* transport gates. Rejects on denial, failed confirmation, validation or execution failure.
|
|
165
|
+
* A composition-owned registry may retain a source registration behind a derived catalog card.
|
|
166
|
+
* Batch wrappers use this port for each final merged input; every supplied registry is gated.
|
|
167
|
+
*/
|
|
168
|
+
readonly invokeTool?: (required: {
|
|
169
|
+
toolId: string;
|
|
170
|
+
input: unknown;
|
|
171
|
+
}, optional?: {
|
|
172
|
+
registry?: ToolRegistry;
|
|
173
|
+
}) => Promise<unknown>;
|
|
148
174
|
}
|
|
149
175
|
/** Runs the tool's actual side effect. Only ever invoked by `ToolExecutor`, never called directly by a route/agent holding the registry. */
|
|
150
176
|
export type ToolHandler = (required: ToolExecutionContext, optional?: ToolExecutionOptions) => Promise<unknown>;
|
|
151
|
-
|
|
152
|
-
* Thrown by a `ToolHandler` (or a validator it calls, e.g. the kernel's `registration-kit.ts`
|
|
153
|
-
* `requireString`/`requireInputRecord`/etc.) to mean "the caller's input was malformed or missing a
|
|
154
|
-
* required field" — a fact about the CALL, not the server. `@jini-ai/daemon`'s `ToolExecutor.execute`
|
|
155
|
-
* tags a handler rejection's `ToolExecutionResult.errorKind` as `'validation'` when the thrown error
|
|
156
|
-
* is `instanceof ToolInputError`, and every HTTP-facing mapping of a `'failed'` execution (today:
|
|
157
|
-
* `@jini-ai/http-kit`'s `delegated-tools.ts`) uses that tag to answer with a 4xx instead of folding
|
|
158
|
-
* it into the same redacted 500 an actual internal/infrastructure failure gets.
|
|
159
|
-
*
|
|
160
|
-
* Deliberately a plain marker with no extra fields: the message itself is already caller-actionable
|
|
161
|
-
* (`withSchemaOnRejection` appends the tool's own input schema to it), so nothing beyond "this IS one
|
|
162
|
-
* of these" needs to survive the `instanceof` check.
|
|
163
|
-
*/
|
|
164
|
-
export declare class ToolInputError extends Error {
|
|
165
|
-
constructor({ message }: {
|
|
166
|
-
message: string;
|
|
167
|
-
}, optional?: ErrorOptions);
|
|
168
|
-
}
|
|
177
|
+
export { ToolInputError } from './tool-input-errors.js';
|
|
169
178
|
export type AuthorizationDecision = 'allow' | 'deny';
|
|
170
179
|
export interface ToolAuthorizationContext {
|
|
171
180
|
readonly principal: Principal;
|
|
@@ -185,13 +194,18 @@ export interface ToolPolicy {
|
|
|
185
194
|
authorize(ctx: ToolAuthorizationContext): AuthorizationDecision | Promise<AuthorizationDecision>;
|
|
186
195
|
}
|
|
187
196
|
export interface ToolRegistration {
|
|
197
|
+
/** Optional execution-only schema, e.g. authenticated form callbacks. Never published by discovery;
|
|
198
|
+
* the handler must retain callback authentication and authorization. Defaults to descriptor.inputSchema. */
|
|
199
|
+
readonly invocationSchema?: unknown;
|
|
200
|
+
/** Domain-owned aliases, read by the single execution gate before validation/authorization. */
|
|
201
|
+
readonly normalizeInput?: ToolInputNormalizer;
|
|
188
202
|
readonly descriptor: ToolDescriptor;
|
|
189
203
|
readonly handler: ToolHandler;
|
|
190
204
|
readonly policy: ToolPolicy;
|
|
191
205
|
}
|
|
192
206
|
/** The public registration/enumeration surface. See the module doc for why `handler`/`policy` never appear here. */
|
|
193
207
|
export interface ToolRegistry {
|
|
194
|
-
/** @throws If `registration.descriptor.id` is already registered — re-registration must be explicit (unregister is not exposed; this registry is append-only by design, matching the kernel's "tools are registered once at composition time" model). */
|
|
208
|
+
/** @throws If the policy has no callable `authorize` or `registration.descriptor.id` is already registered — re-registration must be explicit (unregister is not exposed; this registry is append-only by design, matching the kernel's "tools are registered once at composition time" model). */
|
|
195
209
|
register(registration: ToolRegistration): void;
|
|
196
210
|
has(required: {
|
|
197
211
|
toolId: string;
|
|
@@ -242,6 +256,8 @@ export type ToolInvocationAuthorization = {
|
|
|
242
256
|
readonly descriptor: ToolDescriptor;
|
|
243
257
|
readonly decision: 'allow';
|
|
244
258
|
readonly handler: ToolHandler;
|
|
259
|
+
readonly input: unknown;
|
|
260
|
+
readonly normalizationNotes: readonly string[];
|
|
245
261
|
} | {
|
|
246
262
|
readonly descriptor: ToolDescriptor;
|
|
247
263
|
readonly decision: 'deny';
|
package/dist/tool-registry.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { normalizeToolInput } from './tool-input-normalization.js';
|
|
1
2
|
/**
|
|
2
3
|
* The single read-only determination: may this tool run through a surface that has promised its
|
|
3
4
|
* caller it only reads?
|
|
@@ -13,28 +14,31 @@
|
|
|
13
14
|
export function isReadOnlyTool({ descriptor }) {
|
|
14
15
|
return descriptor?.readOnly === true;
|
|
15
16
|
}
|
|
16
|
-
/**
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
* tags a handler rejection's `ToolExecutionResult.errorKind` as `'validation'` when the thrown error
|
|
21
|
-
* is `instanceof ToolInputError`, and every HTTP-facing mapping of a `'failed'` execution (today:
|
|
22
|
-
* `@jini-ai/http-kit`'s `delegated-tools.ts`) uses that tag to answer with a 4xx instead of folding
|
|
23
|
-
* it into the same redacted 500 an actual internal/infrastructure failure gets.
|
|
24
|
-
*
|
|
25
|
-
* Deliberately a plain marker with no extra fields: the message itself is already caller-actionable
|
|
26
|
-
* (`withSchemaOnRejection` appends the tool's own input schema to it), so nothing beyond "this IS one
|
|
27
|
-
* of these" needs to survive the `instanceof` check.
|
|
17
|
+
/** Project registration risk for discovery using the same read-only decision as execution.
|
|
18
|
+
* Missing classification stays unknown; discovery never grants execution authority.
|
|
19
|
+
* @returns Explicit readOnly and sideEffects fields for a registry descriptor.
|
|
20
|
+
* @complexity O(1); no effects.
|
|
28
21
|
*/
|
|
29
|
-
export
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
this.name = 'ToolInputError';
|
|
33
|
-
}
|
|
22
|
+
export function toolDiscoveryMetadata({ descriptor }, _options = {}) {
|
|
23
|
+
const readOnly = isReadOnlyTool({ descriptor });
|
|
24
|
+
return { readOnly, sideEffects: descriptor?.sideEffects ?? (readOnly ? 'none' : 'unknown') };
|
|
34
25
|
}
|
|
26
|
+
export { ToolInputError } from './tool-input-errors.js';
|
|
35
27
|
const registrationsByRegistry = new WeakMap();
|
|
28
|
+
/** Refuse malformed runtime contributions before either registration path changes the registry. */
|
|
29
|
+
function assertAuthorizationPolicy({ registration }, _optional = {}) {
|
|
30
|
+
if (typeof registration.policy?.authorize !== 'function') {
|
|
31
|
+
throw new Error(`ToolRegistry: tool "${registration.descriptor.id}" must declare an authorization policy`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
36
34
|
// Static register() remains append-only; scoped host contributions below explicitly own removal.
|
|
37
35
|
const registryRevisions = new WeakMap();
|
|
36
|
+
/** Advance a core registry's revision. `createToolRegistry` seeds 0 together with the registry's
|
|
37
|
+
* `registrationsByRegistry` entry, and only such registries reach `register()` or a scope's
|
|
38
|
+
* `replace()` (a scope refuses any other registry), so the seeded entry is always present. */
|
|
39
|
+
function bumpRegistryRevision(registry) {
|
|
40
|
+
registryRevisions.set(registry, registryRevisions.get(registry) + 1);
|
|
41
|
+
}
|
|
38
42
|
/** A host can invalidate its disposable discovery snapshot without exposing handlers. */
|
|
39
43
|
export function toolRegistryRevision(required, _optional = {}) {
|
|
40
44
|
return registryRevisions.get(required.registry);
|
|
@@ -53,6 +57,7 @@ export function createToolRegistrationScope(required, _optional = {}) {
|
|
|
53
57
|
replace({ registrations: next }, _optional = {}) {
|
|
54
58
|
const ids = new Set();
|
|
55
59
|
for (const registration of next) {
|
|
60
|
+
assertAuthorizationPolicy({ registration });
|
|
56
61
|
const id = registration.descriptor.id;
|
|
57
62
|
if (ids.has(id) || (registrations.has(id) && !owned.has(id)))
|
|
58
63
|
throw new Error(`ToolRegistry: tool "${id}" is already registered`);
|
|
@@ -71,7 +76,7 @@ export function createToolRegistrationScope(required, _optional = {}) {
|
|
|
71
76
|
},
|
|
72
77
|
});
|
|
73
78
|
owned = ids;
|
|
74
|
-
|
|
79
|
+
bumpRegistryRevision(required.registry);
|
|
75
80
|
},
|
|
76
81
|
};
|
|
77
82
|
}
|
|
@@ -89,11 +94,12 @@ export function createToolRegistry(_required) {
|
|
|
89
94
|
const registrations = new Map();
|
|
90
95
|
const registry = {
|
|
91
96
|
register(registration) {
|
|
97
|
+
assertAuthorizationPolicy({ registration });
|
|
92
98
|
if (registrations.has(registration.descriptor.id)) {
|
|
93
99
|
throw new Error(`ToolRegistry: tool "${registration.descriptor.id}" is already registered`);
|
|
94
100
|
}
|
|
95
101
|
registrations.set(registration.descriptor.id, registration);
|
|
96
|
-
|
|
102
|
+
bumpRegistryRevision(registry);
|
|
97
103
|
},
|
|
98
104
|
has({ toolId }) {
|
|
99
105
|
return registrations.has(toolId);
|
|
@@ -106,6 +112,10 @@ export function createToolRegistry(_required) {
|
|
|
106
112
|
registryRevisions.set(registry, 0);
|
|
107
113
|
return registry;
|
|
108
114
|
}
|
|
115
|
+
/** Whether a registration has no schema or domain input-normalization hook. O(1), no effects. */
|
|
116
|
+
function isSchemaFreeRegistration({ descriptor, registration }, _options = {}) {
|
|
117
|
+
return descriptor.inputSchema === undefined && registration.invocationSchema === undefined && registration.normalizeInput === undefined;
|
|
118
|
+
}
|
|
109
119
|
/**
|
|
110
120
|
* Package-internal escape hatch: resolves `toolId` in `registry` and runs
|
|
111
121
|
* its authorization gate — the tool's own {@link ToolPolicy}, then an
|
|
@@ -133,10 +143,15 @@ export async function authorizeToolInvocation({ registry, toolId, principal, run
|
|
|
133
143
|
return undefined;
|
|
134
144
|
}
|
|
135
145
|
const { descriptor, handler, policy } = registration;
|
|
146
|
+
// Schema-free tools have nothing to normalize; preserve their authorization/confirmation timing.
|
|
147
|
+
const normalized = isSchemaFreeRegistration({ descriptor, registration })
|
|
148
|
+
? { input, notes: [] }
|
|
149
|
+
: await normalizeToolInput({ input, registration });
|
|
150
|
+
input = normalized.input;
|
|
136
151
|
let decision = await policy.authorize({ principal, run, tool: descriptor, input });
|
|
137
152
|
if (decision === 'allow' && delegate?.onAuthorize) {
|
|
138
153
|
decision = await delegate.onAuthorize({ principal, run, tool: descriptor, input });
|
|
139
154
|
}
|
|
140
|
-
return decision === 'allow' ? { descriptor, decision, handler } : { descriptor, decision: 'deny' };
|
|
155
|
+
return decision === 'allow' ? { descriptor, decision, handler, input, normalizationNotes: normalized.notes } : { descriptor, decision: 'deny' };
|
|
141
156
|
}
|
|
142
157
|
//# sourceMappingURL=tool-registry.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jini-ai/core",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.9",
|
|
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": {
|
|
@@ -18,6 +18,8 @@
|
|
|
18
18
|
"./gated-mutations": "universal",
|
|
19
19
|
"./model-facing-tool-errors": "universal",
|
|
20
20
|
"./contribution-registry": "universal",
|
|
21
|
+
"./provider-registry": "universal",
|
|
22
|
+
"./object-store": "universal",
|
|
21
23
|
"./naming": "universal",
|
|
22
24
|
"./records": "universal",
|
|
23
25
|
"./primitives": "universal",
|
|
@@ -32,7 +34,9 @@
|
|
|
32
34
|
"./primitives": "core",
|
|
33
35
|
"./fetch": "fetch",
|
|
34
36
|
"./settings": "settings",
|
|
35
|
-
"./settings/express": "settings"
|
|
37
|
+
"./settings/express": "settings",
|
|
38
|
+
"./provider-registry": "providers",
|
|
39
|
+
"./object-store": "providers"
|
|
36
40
|
},
|
|
37
41
|
"domains": {
|
|
38
42
|
"fetch": [
|
|
@@ -40,6 +44,10 @@
|
|
|
40
44
|
],
|
|
41
45
|
"settings": [
|
|
42
46
|
"src/settings"
|
|
47
|
+
],
|
|
48
|
+
"providers": [
|
|
49
|
+
"src/provider-registry.ts",
|
|
50
|
+
"src/object-store.ts"
|
|
43
51
|
]
|
|
44
52
|
},
|
|
45
53
|
"shared": [
|
|
@@ -98,6 +106,16 @@
|
|
|
98
106
|
"import": "./dist/contribution-registry.js",
|
|
99
107
|
"default": "./dist/contribution-registry.js"
|
|
100
108
|
},
|
|
109
|
+
"./provider-registry": {
|
|
110
|
+
"types": "./dist/provider-registry.d.ts",
|
|
111
|
+
"import": "./dist/provider-registry.js",
|
|
112
|
+
"default": "./dist/provider-registry.js"
|
|
113
|
+
},
|
|
114
|
+
"./object-store": {
|
|
115
|
+
"types": "./dist/object-store.d.ts",
|
|
116
|
+
"import": "./dist/object-store.js",
|
|
117
|
+
"default": "./dist/object-store.js"
|
|
118
|
+
},
|
|
101
119
|
"./naming": {
|
|
102
120
|
"types": "./dist/naming.d.ts",
|
|
103
121
|
"import": "./dist/naming.js",
|
package/plugin-directory.js
CHANGED
|
@@ -8,6 +8,34 @@ const LEGACY_NAME_PATTERN = /^[a-z0-9.-]+$/;
|
|
|
8
8
|
const CAPABILITY_PATTERN = new RegExp(`^${SEGMENT}(?::${SEGMENT})?$`);
|
|
9
9
|
const object = value => typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
10
10
|
|
|
11
|
+
/** A declared id is malformed when it is not a bounded publisher/name string. */
|
|
12
|
+
const malformedId = id => typeof id !== 'string' || id.length > 129 || !ID_PATTERN.test(id);
|
|
13
|
+
const validCapability = p => typeof p === 'string' && p.length <= 129 && CAPABILITY_PATTERN.test(p);
|
|
14
|
+
const legacyIdFor = localName => `unknown/${localName}`;
|
|
15
|
+
|
|
16
|
+
/** The first shape error that stops identity validation before any field is interpreted. */
|
|
17
|
+
function structuralError({ manifest, namespace }) {
|
|
18
|
+
if (!object(manifest)) return 'Plugin manifest must be an object.';
|
|
19
|
+
if (manifest.extensions !== undefined && !object(manifest.extensions)) return 'Plugin extensions must be an object.';
|
|
20
|
+
const extension = manifest.extensions?.[namespace];
|
|
21
|
+
if (extension !== undefined && !object(extension)) return 'Plugin identity extension must be an object.';
|
|
22
|
+
return undefined;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Errors for a declared id; an absent id is a legacy identity, not an error. */
|
|
26
|
+
function idErrors({ id }) {
|
|
27
|
+
if (id === undefined) return [];
|
|
28
|
+
if (malformedId(id)) return ['Plugin id must have the form publisher/name (lowercase letters, digits, dots and hyphens).'];
|
|
29
|
+
// Only the host may infer a legacy identity; a publisher cannot claim its upgrade exemption.
|
|
30
|
+
return id.startsWith('unknown/') ? ["Plugin publisher 'unknown' is reserved for legacy identities."] : [];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Errors for a declared provides list; one malformed entry rejects the list rather than being dropped. */
|
|
34
|
+
function providesErrors({ provides }) {
|
|
35
|
+
const valid = provides === undefined || (Array.isArray(provides) && provides.every(validCapability));
|
|
36
|
+
return valid ? [] : ['Plugin provides must be an array of capability names.'];
|
|
37
|
+
}
|
|
38
|
+
|
|
11
39
|
/** Collects identity errors without mutating the portable manifest. Absent metadata is legacy.
|
|
12
40
|
* @param {{manifest: unknown, namespace: string}} required
|
|
13
41
|
* @param {Record<string, never>} [optional]
|
|
@@ -15,22 +43,11 @@ const object = value => typeof value === 'object' && value !== null && !Array.is
|
|
|
15
43
|
* @complexity O(k + p) in keyword and provides entries; no I/O.
|
|
16
44
|
*/
|
|
17
45
|
export function validatePluginIdentity({ manifest, namespace }, optional = {}) {
|
|
18
|
-
const
|
|
19
|
-
if (
|
|
20
|
-
if (manifest.extensions !== undefined && !object(manifest.extensions)) return { errors: [...errors, 'Plugin extensions must be an object.'] };
|
|
46
|
+
const structural = structuralError({ manifest, namespace });
|
|
47
|
+
if (structural !== undefined) return { errors: [structural] };
|
|
21
48
|
const extension = manifest.extensions?.[namespace];
|
|
22
|
-
if (extension === undefined) return { errors };
|
|
23
|
-
|
|
24
|
-
if (extension.id !== undefined && (typeof extension.id !== 'string' || extension.id.length > 129 || !ID_PATTERN.test(extension.id))) {
|
|
25
|
-
errors.push('Plugin id must have the form publisher/name (lowercase letters, digits, dots and hyphens).');
|
|
26
|
-
} else if (typeof extension.id === 'string' && extension.id.startsWith('unknown/')) {
|
|
27
|
-
// Only the host may infer a legacy identity; a publisher cannot claim its upgrade exemption.
|
|
28
|
-
errors.push("Plugin publisher 'unknown' is reserved for legacy identities.");
|
|
29
|
-
}
|
|
30
|
-
if (extension.provides !== undefined && (!Array.isArray(extension.provides) || !extension.provides.every(p => typeof p === 'string' && p.length <= 129 && CAPABILITY_PATTERN.test(p)))) {
|
|
31
|
-
errors.push('Plugin provides must be an array of capability names.');
|
|
32
|
-
}
|
|
33
|
-
return { errors };
|
|
49
|
+
if (extension === undefined) return { errors: [] };
|
|
50
|
+
return { errors: [...idErrors({ id: extension.id }), ...providesErrors({ provides: extension.provides })] };
|
|
34
51
|
}
|
|
35
52
|
|
|
36
53
|
/** Resolves one identity; warns on missing ids and refuses malformed declarations.
|
|
@@ -45,20 +62,33 @@ export function readPluginIdentity({ manifest, namespace, name }, optional = {})
|
|
|
45
62
|
const { errors } = validatePluginIdentity({ manifest, namespace });
|
|
46
63
|
if (errors.length) throw new TypeError(errors.join(' '));
|
|
47
64
|
const extension = manifest.extensions?.[namespace];
|
|
65
|
+
const declaredId = extension?.id;
|
|
48
66
|
const localName = name ?? manifest.name;
|
|
49
|
-
|
|
67
|
+
const id = declaredId ?? legacyIdentity({ localName });
|
|
68
|
+
const keywords = normalizeKeywords({ raw: manifest.keywords });
|
|
69
|
+
const warnings = declaredId === undefined ? [`Plugin '${localName}' has no publisher-qualified id; using '${id}'.`] : [];
|
|
70
|
+
return { id, keywords, provides: [...(extension?.provides ?? [])], warnings: [...warnings, ...keywordWarnings({ raw: manifest.keywords, keywords })] };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The host-inferred `unknown/<name>` id, refusing a name the legacy grammar never accepted. */
|
|
74
|
+
function legacyIdentity({ localName }) {
|
|
75
|
+
if (typeof localName !== 'string' || !LEGACY_NAME_PATTERN.test(localName)) {
|
|
50
76
|
throw new TypeError('A valid plugin name is required for a legacy identity.');
|
|
51
77
|
}
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
78
|
+
return legacyIdFor(localName);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Keywords are advisory portable metadata: older manifests accepted mixed or non-array values.
|
|
82
|
+
* The portable parser's coercion (non-string entries dropped, non-arrays ignored) lives here
|
|
83
|
+
* so validation and lookup normalize advisory metadata through the same owner. */
|
|
84
|
+
function normalizeKeywords({ raw }) {
|
|
85
|
+
return Array.isArray(raw) ? raw.filter(k => typeof k === 'string' && k.trim().length > 0) : [];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** One warning when normalization ignored any declared keyword (or a non-array value). */
|
|
89
|
+
function keywordWarnings({ raw, keywords }) {
|
|
90
|
+
const ignored = raw !== undefined && (!Array.isArray(raw) || keywords.length !== raw.length);
|
|
91
|
+
return ignored ? ['Plugin keywords must be an array of nonempty strings; invalid keywords were ignored.'] : [];
|
|
62
92
|
}
|
|
63
93
|
|
|
64
94
|
/** Rejects id reuse and publisher changes at an existing storage slot. Legacy metadata may
|
|
@@ -72,16 +102,25 @@ export function readPluginIdentity({ manifest, namespace, name }, optional = {})
|
|
|
72
102
|
export function assertPluginIdentityAvailable({ candidate, installed }, optional = {}) {
|
|
73
103
|
for (const plugin of installed) {
|
|
74
104
|
const sameSlot = plugin.family === candidate.family && plugin.localId === candidate.localId;
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
throw new Error(`Plugin '${candidate.localId}' belongs to a different publisher identity.`);
|
|
78
|
-
}
|
|
79
|
-
} else if (plugin.id === candidate.id && !(plugin.family !== candidate.family && candidate.id === `unknown/${candidate.localId}` && plugin.id === `unknown/${plugin.localId}`)) {
|
|
80
|
-
throw new Error(`Duplicate plugin id '${candidate.id}'.`);
|
|
81
|
-
}
|
|
105
|
+
const conflict = sameSlot ? sameSlotConflict({ plugin, candidate }) : otherSlotConflict({ plugin, candidate });
|
|
106
|
+
if (conflict !== undefined) throw new Error(conflict);
|
|
82
107
|
}
|
|
83
108
|
}
|
|
84
109
|
|
|
110
|
+
/** A provider already in the candidate's slot may only be the same identity or its legacy fallback. */
|
|
111
|
+
function sameSlotConflict({ plugin, candidate }) {
|
|
112
|
+
const compatible = plugin.id === candidate.id || plugin.id === legacyIdFor(candidate.localId);
|
|
113
|
+
return compatible ? undefined : `Plugin '${candidate.localId}' belongs to a different publisher identity.`;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** A provider in another slot may not reuse the candidate's id, except equal legacy fallback ids,
|
|
117
|
+
* which are family-scoped and so name distinct plugins in different families. */
|
|
118
|
+
function otherSlotConflict({ plugin, candidate }) {
|
|
119
|
+
if (plugin.id !== candidate.id) return undefined;
|
|
120
|
+
const crossFamilyLegacy = plugin.family !== candidate.family && candidate.id === legacyIdFor(candidate.localId) && plugin.id === legacyIdFor(plugin.localId);
|
|
121
|
+
return crossFamilyLegacy ? undefined : `Duplicate plugin id '${candidate.id}'.`;
|
|
122
|
+
}
|
|
123
|
+
|
|
85
124
|
/** Restricts an SDK handle at runtime, not just through a TypeScript interface. A host may carry
|
|
86
125
|
* lifecycle mutation ports, but a plugin's discovery surface exposes only these metadata reads.
|
|
87
126
|
* @param {{lookup: import('./plugin-directory.js').PluginLookupPort}} required
|