@zhin.js/plugin-runtime 1.1.16 → 1.1.17
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/lib/database-host.d.ts +7 -2
- package/lib/database-host.js +4 -2
- package/lib/i18n.d.ts +24 -0
- package/lib/i18n.js +38 -0
- package/lib/index.d.ts +2 -0
- package/lib/index.js +2 -0
- package/lib/outbound-host.d.ts +9 -2
- package/lib/outbound-host.js +3 -1
- package/lib/plugin-console-rpc.d.ts +86 -0
- package/lib/plugin-console-rpc.js +69 -0
- package/lib/plugin-tasks.d.ts +11 -1
- package/lib/plugin-verification.d.ts +5 -0
- package/lib/relational-state.d.ts +2 -0
- package/lib/relational-state.js +8 -2
- package/lib/root-config-document.js +19 -3
- package/package.json +1 -1
package/lib/database-host.d.ts
CHANGED
|
@@ -44,6 +44,11 @@ export interface DatabaseHostTable {
|
|
|
44
44
|
readonly name: string;
|
|
45
45
|
readonly columns?: Readonly<Record<string, unknown>>;
|
|
46
46
|
}
|
|
47
|
+
/** Explicit, additive upgrades applied before SQL models become available. */
|
|
48
|
+
export interface DatabaseTableOptions {
|
|
49
|
+
/** Names from this table's definition. Existing columns and data remain untouched. */
|
|
50
|
+
readonly addColumns?: readonly string[];
|
|
51
|
+
}
|
|
47
52
|
export interface DatabaseHostSelectResult {
|
|
48
53
|
readonly rows: readonly unknown[];
|
|
49
54
|
readonly total: number;
|
|
@@ -83,7 +88,7 @@ export interface DatabaseHost {
|
|
|
83
88
|
readonly dialect: string;
|
|
84
89
|
readonly started: boolean;
|
|
85
90
|
/** Register a table schema before `start` (column defs match `@zhin.js/database`). */
|
|
86
|
-
define(name: string, definition: Record<string, unknown
|
|
91
|
+
define(name: string, definition: Record<string, unknown>, options?: DatabaseTableOptions): void;
|
|
87
92
|
/** Names of tables registered via `define` (Console `db:tables`). */
|
|
88
93
|
tables(): readonly string[];
|
|
89
94
|
models: {
|
|
@@ -129,7 +134,7 @@ export declare class PluginDatabaseHost extends OwnerScopedResourceHost {
|
|
|
129
134
|
get [generationAdmissionSource](): readonly GenerationAdmissionGate[];
|
|
130
135
|
get dialect(): string;
|
|
131
136
|
get started(): boolean;
|
|
132
|
-
define(name: string, definition: Record<string, unknown
|
|
137
|
+
define(name: string, definition: Record<string, unknown>, options?: DatabaseTableOptions): void;
|
|
133
138
|
tables(): readonly string[];
|
|
134
139
|
transaction<T>(callback: (transaction: DatabaseHostTransaction) => Promise<T>): Promise<T>;
|
|
135
140
|
}
|
package/lib/database-host.js
CHANGED
|
@@ -54,8 +54,10 @@ export class PluginDatabaseHost extends OwnerScopedResourceHost {
|
|
|
54
54
|
get started() {
|
|
55
55
|
return this.#host.started;
|
|
56
56
|
}
|
|
57
|
-
define(name, definition) {
|
|
58
|
-
this.#operation.setup(() =>
|
|
57
|
+
define(name, definition, options) {
|
|
58
|
+
this.#operation.setup(() => options === undefined
|
|
59
|
+
? this.#host.define(this.qualify(name), definition)
|
|
60
|
+
: this.#host.define(this.qualify(name), definition, options));
|
|
59
61
|
}
|
|
60
62
|
tables() {
|
|
61
63
|
return this.#operation.setup(() => Object.freeze(this.#host.tables().flatMap((name) => {
|
package/lib/i18n.d.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { DeliveryTarget } from './delivery-target.js';
|
|
2
|
+
/** Supported display languages. Time zone and command identities are independent. */
|
|
3
|
+
export type Locale = 'zh-CN' | 'en';
|
|
4
|
+
export declare const DEFAULT_LOCALE: Locale;
|
|
5
|
+
export declare const SUPPORTED_LOCALES: readonly ["zh-CN", "en"];
|
|
6
|
+
export declare function isLocale(value: unknown): value is Locale;
|
|
7
|
+
/** Resolve an absent display preference; configuration writes validate with isLocale. */
|
|
8
|
+
export declare function resolveLocale(value: unknown): Locale;
|
|
9
|
+
/** Immutable project language, projected with the operation's generation. */
|
|
10
|
+
export declare const projectLocaleToken: import("./token.js").Token<Locale>;
|
|
11
|
+
/** Root-owned preferences; identity never comes from display names or profile text. */
|
|
12
|
+
export interface UserLocalePreferences {
|
|
13
|
+
get(endpoint: DeliveryTarget['endpoint'], senderId: string): Promise<Locale | undefined>;
|
|
14
|
+
set(endpoint: DeliveryTarget['endpoint'], senderId: string, locale: Locale): Promise<void>;
|
|
15
|
+
}
|
|
16
|
+
export declare const userLocalePreferencesToken: import("./token.js").Token<UserLocalePreferences>;
|
|
17
|
+
export type MessageCatalog = Readonly<Record<string, Readonly<Record<Locale, string>>>>;
|
|
18
|
+
export type MessageValues = Readonly<Record<string, string | number>>;
|
|
19
|
+
/**
|
|
20
|
+
* A package-owned, complete bilingual catalog. No global language or registration.
|
|
21
|
+
* Placeholder substitution is one pass: user text cannot become another template.
|
|
22
|
+
* Values remain plain text; HTML/JSX escaping belongs to the existing renderer.
|
|
23
|
+
*/
|
|
24
|
+
export declare function createMessages<const T extends MessageCatalog>(catalog: T): (locale: Locale | undefined, key: keyof T & string, values?: MessageValues) => string;
|
package/lib/i18n.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { createToken } from './token.js';
|
|
2
|
+
export const DEFAULT_LOCALE = 'zh-CN';
|
|
3
|
+
export const SUPPORTED_LOCALES = Object.freeze(['zh-CN', 'en']);
|
|
4
|
+
export function isLocale(value) {
|
|
5
|
+
return value === 'zh-CN' || value === 'en';
|
|
6
|
+
}
|
|
7
|
+
/** Resolve an absent display preference; configuration writes validate with isLocale. */
|
|
8
|
+
export function resolveLocale(value) {
|
|
9
|
+
return isLocale(value) ? value : DEFAULT_LOCALE;
|
|
10
|
+
}
|
|
11
|
+
/** Immutable project language, projected with the operation's generation. */
|
|
12
|
+
export const projectLocaleToken = createToken('zhin.project-locale');
|
|
13
|
+
export const userLocalePreferencesToken = createToken('zhin.user-locale-preferences');
|
|
14
|
+
/**
|
|
15
|
+
* A package-owned, complete bilingual catalog. No global language or registration.
|
|
16
|
+
* Placeholder substitution is one pass: user text cannot become another template.
|
|
17
|
+
* Values remain plain text; HTML/JSX escaping belongs to the existing renderer.
|
|
18
|
+
*/
|
|
19
|
+
export function createMessages(catalog) {
|
|
20
|
+
const entries = Object.freeze(Object.fromEntries(Object.entries(catalog).map(([key, value]) => {
|
|
21
|
+
const keys = (text) => [...new Set([...text.matchAll(/\{([a-zA-Z][\w]*)\}/gu)].map(match => match[1]))].sort();
|
|
22
|
+
if (typeof value['zh-CN'] !== 'string' || typeof value.en !== 'string'
|
|
23
|
+
|| JSON.stringify(keys(value['zh-CN'])) !== JSON.stringify(keys(value.en))) {
|
|
24
|
+
throw new TypeError(`Invalid bilingual message catalog entry: ${key}`);
|
|
25
|
+
}
|
|
26
|
+
return [key, Object.freeze({ ...value })];
|
|
27
|
+
})));
|
|
28
|
+
return (locale, key, values = {}) => {
|
|
29
|
+
const entry = entries[key];
|
|
30
|
+
if (!Object.hasOwn(entries, key) || !entry)
|
|
31
|
+
throw new TypeError(`Unknown message catalog entry: ${key}`);
|
|
32
|
+
return entry[resolveLocale(locale)].replace(/\{([a-zA-Z][\w]*)\}/gu, (_match, name) => {
|
|
33
|
+
if (!Object.hasOwn(values, name))
|
|
34
|
+
throw new TypeError(`Missing message value: ${name}`);
|
|
35
|
+
return String(values[name]);
|
|
36
|
+
});
|
|
37
|
+
};
|
|
38
|
+
}
|
package/lib/index.d.ts
CHANGED
|
@@ -12,6 +12,7 @@ export * from './handoff.js';
|
|
|
12
12
|
export * from './plugin.js';
|
|
13
13
|
/** @public Root configuration contract shared by Runtime and composition tooling. */
|
|
14
14
|
export * from './plugin-configuration.js';
|
|
15
|
+
export * from './i18n.js';
|
|
15
16
|
export * from './root-config-file.js';
|
|
16
17
|
export * from './root-config-document.js';
|
|
17
18
|
/** @internal Runtime 内部:RootRuntime / RootController。 */
|
|
@@ -39,3 +40,4 @@ export * from './system-log.js';
|
|
|
39
40
|
export * from './plugin-verification.js';
|
|
40
41
|
export * from './plugin-tasks.js';
|
|
41
42
|
export * from './task-storage.js';
|
|
43
|
+
export * from './plugin-console-rpc.js';
|
package/lib/index.js
CHANGED
|
@@ -12,6 +12,7 @@ export * from './handoff.js';
|
|
|
12
12
|
export * from './plugin.js';
|
|
13
13
|
/** @public Root configuration contract shared by Runtime and composition tooling. */
|
|
14
14
|
export * from './plugin-configuration.js';
|
|
15
|
+
export * from './i18n.js';
|
|
15
16
|
export * from './root-config-file.js';
|
|
16
17
|
export * from './root-config-document.js';
|
|
17
18
|
/** @internal Runtime 内部:RootRuntime / RootController。 */
|
|
@@ -39,3 +40,4 @@ export * from './system-log.js';
|
|
|
39
40
|
export * from './plugin-verification.js';
|
|
40
41
|
export * from './plugin-tasks.js';
|
|
41
42
|
export * from './task-storage.js';
|
|
43
|
+
export * from './plugin-console-rpc.js';
|
package/lib/outbound-host.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { DeliveryTarget } from './delivery-target.js';
|
|
2
2
|
import type { PluginId } from './identity.js';
|
|
3
|
+
import type { Locale } from './i18n.js';
|
|
3
4
|
import { generationAdmissionBinder, generationAdmissionSource, type GenerationAdmissionGate } from './admission.js';
|
|
4
5
|
/**
|
|
5
6
|
* 未锚定 endpoint 的会话地址(结构对齐 `@zhin.js/im-contract` 的
|
|
@@ -21,6 +22,8 @@ export interface OutboundConversation {
|
|
|
21
22
|
* `sendEndpointMessage` (resolve adapter/endpoint → AdapterIndex.send).
|
|
22
23
|
*/
|
|
23
24
|
export interface OutboundSendInput {
|
|
25
|
+
/** Language captured by the originating operation or persisted task. */
|
|
26
|
+
readonly locale?: Locale;
|
|
24
27
|
readonly adapter: string;
|
|
25
28
|
readonly endpointKey: string;
|
|
26
29
|
readonly conversation: OutboundConversation;
|
|
@@ -73,7 +76,9 @@ export interface OutboundMessage {
|
|
|
73
76
|
}
|
|
74
77
|
export interface OutboundHost {
|
|
75
78
|
/** Sends to persisted exact identities, rejecting stale addresses instead of aliasing another bot. */
|
|
76
|
-
sendTarget?(target: DeliveryTarget, content: unknown
|
|
79
|
+
sendTarget?(target: DeliveryTarget, content: unknown, options?: Readonly<{
|
|
80
|
+
locale?: Locale;
|
|
81
|
+
}>): Promise<string | null>;
|
|
77
82
|
/** Runs related operations against one IM Runtime snapshot captured at ingress. */
|
|
78
83
|
runWithView?<T>(operation: () => Promise<T>): Promise<T>;
|
|
79
84
|
/** Exact operations declared by one live Endpoint. */
|
|
@@ -95,7 +100,9 @@ export interface OutboundHost {
|
|
|
95
100
|
}
|
|
96
101
|
/** Root-only process port: requester is supplied by the owner-bound facade. */
|
|
97
102
|
export interface OutboundRootHost extends Omit<OutboundHost, 'sendTarget'> {
|
|
98
|
-
sendTarget?(target: DeliveryTarget, content: unknown, requester: PluginId
|
|
103
|
+
sendTarget?(target: DeliveryTarget, content: unknown, requester: PluginId, options?: Readonly<{
|
|
104
|
+
locale?: Locale;
|
|
105
|
+
}>): Promise<string | null>;
|
|
99
106
|
}
|
|
100
107
|
/** Preserve the sending plugin as requester, independent of the destination endpoint owner. */
|
|
101
108
|
export declare class PluginOutboundHost implements OutboundHost {
|
package/lib/outbound-host.js
CHANGED
|
@@ -28,7 +28,9 @@ export class PluginOutboundHost {
|
|
|
28
28
|
const recall = host.recall?.bind(host);
|
|
29
29
|
const edit = host.edit?.bind(host);
|
|
30
30
|
const typing = host.typing?.bind(host);
|
|
31
|
-
this.sendTarget = sendTarget ? async (target, content) => this.#run(() =>
|
|
31
|
+
this.sendTarget = sendTarget ? async (target, content, options) => this.#run(() => options
|
|
32
|
+
? sendTarget(target, content, this.#owner, options)
|
|
33
|
+
: sendTarget(target, content, this.#owner)) : undefined;
|
|
32
34
|
this.runWithView = runWithView ? (operation) => this.#run(() => runWithView(operation)) : undefined;
|
|
33
35
|
this.capabilities = capabilities ? (input) => this.#run(() => capabilities(input)) : undefined;
|
|
34
36
|
this.addReaction = addReaction ? (input) => this.#run(() => addReaction(input)) : undefined;
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { type GenerationAdmissionSource } from './admission.js';
|
|
2
|
+
import type { JsonValue } from './persistent-state.js';
|
|
3
|
+
/**
|
|
4
|
+
* Plugin-registered Console RPC (zhinjs/zhin#970).
|
|
5
|
+
*
|
|
6
|
+
* A plugin publishes one {@link PluginConsoleRpc} through {@link pluginConsoleRpcToken}. The Host
|
|
7
|
+
* exposes it on the existing Console RPC envelope as `plugin.<pluginId>.status` and
|
|
8
|
+
* `plugin.<pluginId>.<action>`, where `<pluginId>` is the owner PluginId (for example
|
|
9
|
+
* `root/typesafe`). No WebSocket, no new HTTP route, and no generic config writes.
|
|
10
|
+
*
|
|
11
|
+
* Authorization follows the Console Page the method is bound to (`page`): the caller must
|
|
12
|
+
* satisfy that page's `requiredPermissions` / `requiredRoles`, plus the method's own. The full
|
|
13
|
+
* (admin) token may call everything. Other tokens (`user` / `demo`) may call a method when they
|
|
14
|
+
* hold every required permission and role. An action is open to them only when it declares its
|
|
15
|
+
* own `requiredPermissions` / `requiredRoles` / `anyOfPermissions` (the page's requirements apply on
|
|
16
|
+
* top, but never grant an action by themselves); an unguarded `status` needs `demo: true`.
|
|
17
|
+
* `anyOfPermissions` is satisfied by holding at least one of the listed permissions (for a new
|
|
18
|
+
* permission that coexists with an older one, e.g. `ops:approve` beside `tool_approvals:resolve`).
|
|
19
|
+
*/
|
|
20
|
+
export type PluginConsoleRpcJson = JsonValue;
|
|
21
|
+
export type PluginConsoleRpcInput = Readonly<Record<string, JsonValue>>;
|
|
22
|
+
export interface PluginConsoleRpcCall {
|
|
23
|
+
/** Aborted when the Host request times out. Long work must observe it. */
|
|
24
|
+
readonly signal: AbortSignal;
|
|
25
|
+
}
|
|
26
|
+
export interface PluginConsoleRpcMethod<TInput = PluginConsoleRpcInput> {
|
|
27
|
+
/** Local name of a Page owned by the same plugin (`pages/<page>/index.tsx`). Its access rules apply. */
|
|
28
|
+
readonly page?: string;
|
|
29
|
+
/** Extra permissions on top of the page's own requirements. */
|
|
30
|
+
readonly requiredPermissions?: readonly string[];
|
|
31
|
+
/** Extra roles on top of the page's own requirements. */
|
|
32
|
+
readonly requiredRoles?: readonly string[];
|
|
33
|
+
/**
|
|
34
|
+
* The caller must also hold at least one of these (in addition to every `requiredPermissions`).
|
|
35
|
+
* Counts as the method's own requirement for actions.
|
|
36
|
+
*/
|
|
37
|
+
readonly anyOfPermissions?: readonly string[];
|
|
38
|
+
/**
|
|
39
|
+
* Return a small, user-facing JSON shape. Never return raw log records, prompts, chat lines,
|
|
40
|
+
* secrets or exception text: the Console shows this to ordinary users.
|
|
41
|
+
*/
|
|
42
|
+
handler(input: TInput, call: PluginConsoleRpcCall): JsonValue | Promise<JsonValue>;
|
|
43
|
+
}
|
|
44
|
+
export interface PluginConsoleRpcStatus extends PluginConsoleRpcMethod<PluginConsoleRpcInput> {
|
|
45
|
+
/** Allow the demo-scope Console token to read this status. Default `false`. */
|
|
46
|
+
readonly demo?: boolean;
|
|
47
|
+
}
|
|
48
|
+
export interface PluginConsoleRpcDefinition {
|
|
49
|
+
/** Read-only status for the plugin's Console page(s): `plugin.<pluginId>.status`. */
|
|
50
|
+
readonly status?: PluginConsoleRpcStatus;
|
|
51
|
+
/**
|
|
52
|
+
* Extra read-only views beside `status`: `plugin.<pluginId>.<name>`. Same access rules as
|
|
53
|
+
* `status`; use them when one plugin backs several pages with different permissions.
|
|
54
|
+
*/
|
|
55
|
+
readonly queries?: Readonly<Record<string, PluginConsoleRpcStatus>>;
|
|
56
|
+
/**
|
|
57
|
+
* Limited, named actions: `plugin.<pluginId>.<name>`. Non-admin tokens may run one only when the
|
|
58
|
+
* action declares its own requirements and the token holds them (plus the page's); otherwise
|
|
59
|
+
* admin-only.
|
|
60
|
+
*/
|
|
61
|
+
readonly actions?: Readonly<Record<string, PluginConsoleRpcMethod>>;
|
|
62
|
+
}
|
|
63
|
+
export interface NormalizedPluginConsoleRpcMethod {
|
|
64
|
+
readonly page?: string;
|
|
65
|
+
readonly requiredPermissions: readonly string[];
|
|
66
|
+
readonly requiredRoles: readonly string[];
|
|
67
|
+
/** Empty when the method has no either-of requirement. */
|
|
68
|
+
readonly anyOfPermissions: readonly string[];
|
|
69
|
+
readonly demo: boolean;
|
|
70
|
+
/** `status` and `queries` are read-only; actions are not. */
|
|
71
|
+
readonly readOnly: boolean;
|
|
72
|
+
readonly handler: PluginConsoleRpcMethod['handler'];
|
|
73
|
+
}
|
|
74
|
+
/** Value published under {@link pluginConsoleRpcToken}; create it with {@link definePluginConsoleRpc}. */
|
|
75
|
+
export interface PluginConsoleRpc extends GenerationAdmissionSource {
|
|
76
|
+
readonly methods: ReadonlyMap<string, NormalizedPluginConsoleRpcMethod>;
|
|
77
|
+
}
|
|
78
|
+
/** Safe, user-facing failure. `message` is shown in the Console; never put raw errors in it. */
|
|
79
|
+
export declare class PluginConsoleRpcError extends Error {
|
|
80
|
+
readonly code: 'invalid_input' | 'conflict' | 'not_found' | 'unsupported' | 'unavailable';
|
|
81
|
+
constructor(code: PluginConsoleRpcError['code'], message: string);
|
|
82
|
+
}
|
|
83
|
+
export declare const pluginConsoleRpcToken: import("./token.js").Token<PluginConsoleRpc>;
|
|
84
|
+
export declare const PLUGIN_CONSOLE_RPC_STATUS = "status";
|
|
85
|
+
/** Validates and freezes a plugin's Console RPC surface. Throws on invalid names at setup time. */
|
|
86
|
+
export declare function definePluginConsoleRpc(definition: PluginConsoleRpcDefinition): PluginConsoleRpc;
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { createGenerationAdmissionGate, generationAdmissionSource, } from './admission.js';
|
|
2
|
+
import { createToken } from './token.js';
|
|
3
|
+
/** Safe, user-facing failure. `message` is shown in the Console; never put raw errors in it. */
|
|
4
|
+
export class PluginConsoleRpcError extends Error {
|
|
5
|
+
code;
|
|
6
|
+
constructor(code, message) {
|
|
7
|
+
super(message);
|
|
8
|
+
this.name = 'PluginConsoleRpcError';
|
|
9
|
+
this.code = code;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
export const pluginConsoleRpcToken = createToken('zhin.plugin-console-rpc', 'Plugin-owned Console status and limited actions, called through the authenticated Console RPC envelope');
|
|
13
|
+
export const PLUGIN_CONSOLE_RPC_STATUS = 'status';
|
|
14
|
+
const methodNamePattern = /^[a-z][a-z0-9-]{0,63}$/u;
|
|
15
|
+
const pageNamePattern = /^[a-z0-9][a-z0-9-]*$/u;
|
|
16
|
+
/** Validates and freezes a plugin's Console RPC surface. Throws on invalid names at setup time. */
|
|
17
|
+
export function definePluginConsoleRpc(definition) {
|
|
18
|
+
if (!definition || typeof definition !== 'object')
|
|
19
|
+
throw new TypeError('Plugin Console RPC definition must be an object');
|
|
20
|
+
const methods = new Map();
|
|
21
|
+
if (definition.status !== undefined) {
|
|
22
|
+
methods.set(PLUGIN_CONSOLE_RPC_STATUS, normalizeMethod('status', definition.status, definition.status.demo === true, true));
|
|
23
|
+
}
|
|
24
|
+
for (const [kind, entries] of [['query', definition.queries], ['action', definition.actions]]) {
|
|
25
|
+
for (const [name, method] of Object.entries(entries ?? {})) {
|
|
26
|
+
if (!methodNamePattern.test(name))
|
|
27
|
+
throw new TypeError(`Invalid plugin Console RPC ${kind} name: ${name}`);
|
|
28
|
+
if (name === PLUGIN_CONSOLE_RPC_STATUS)
|
|
29
|
+
throw new TypeError('"status" is reserved; declare it as definition.status');
|
|
30
|
+
if (methods.has(name))
|
|
31
|
+
throw new TypeError(`Duplicate plugin Console RPC method: ${name}`);
|
|
32
|
+
const readOnly = kind === 'query';
|
|
33
|
+
methods.set(name, normalizeMethod(name, method, readOnly && method.demo === true, readOnly));
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
if (methods.size === 0)
|
|
37
|
+
throw new TypeError('Plugin Console RPC must declare status or at least one action');
|
|
38
|
+
const gate = createGenerationAdmissionGate();
|
|
39
|
+
const gates = Object.freeze([gate]);
|
|
40
|
+
return Object.freeze({
|
|
41
|
+
methods: methods,
|
|
42
|
+
get [generationAdmissionSource]() { return gates; },
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
function normalizeMethod(name, method, demo, readOnly) {
|
|
46
|
+
if (!method || typeof method.handler !== 'function') {
|
|
47
|
+
throw new TypeError(`Plugin Console RPC ${name} must have a handler`);
|
|
48
|
+
}
|
|
49
|
+
if (method.page !== undefined && (typeof method.page !== 'string' || !pageNamePattern.test(method.page))) {
|
|
50
|
+
throw new TypeError(`Plugin Console RPC ${name} has an invalid page name`);
|
|
51
|
+
}
|
|
52
|
+
return Object.freeze({
|
|
53
|
+
...(method.page === undefined ? {} : { page: method.page }),
|
|
54
|
+
requiredPermissions: stringList(method.requiredPermissions, `${name} requiredPermissions`),
|
|
55
|
+
requiredRoles: stringList(method.requiredRoles, `${name} requiredRoles`),
|
|
56
|
+
anyOfPermissions: stringList(method.anyOfPermissions, `${name} anyOfPermissions`),
|
|
57
|
+
demo,
|
|
58
|
+
readOnly,
|
|
59
|
+
handler: method.handler,
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
function stringList(value, label) {
|
|
63
|
+
if (value === undefined)
|
|
64
|
+
return Object.freeze([]);
|
|
65
|
+
if (!Array.isArray(value) || !value.every((item) => typeof item === 'string' && item.trim())) {
|
|
66
|
+
throw new TypeError(`Plugin Console RPC ${label} must be non-empty strings`);
|
|
67
|
+
}
|
|
68
|
+
return Object.freeze([...new Set(value)].sort());
|
|
69
|
+
}
|
package/lib/plugin-tasks.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { DeliveryTarget } from './delivery-target.js';
|
|
2
2
|
import type { JsonValue } from './persistent-state.js';
|
|
3
3
|
import type { GenerationAdmissionSource } from './admission.js';
|
|
4
|
+
import type { Locale } from './i18n.js';
|
|
4
5
|
export type PluginTaskState = 'scheduled' | 'paused' | 'running' | 'unknown' | 'succeeded' | 'failed' | 'cancelled';
|
|
5
6
|
export type PluginTaskAction = 'pause' | 'resume' | 'cancel' | 'update';
|
|
6
7
|
/** Business task facts. Chat-user tasks are deliberately outside this administrator port. */
|
|
@@ -31,19 +32,28 @@ export interface PluginTaskMutationReceipt {
|
|
|
31
32
|
readonly operation: 'create' | PluginTaskAction;
|
|
32
33
|
}
|
|
33
34
|
export interface PluginTaskMutation {
|
|
35
|
+
/** Display language only; excluded from mutation identity and notification settings. */
|
|
36
|
+
readonly locale?: Locale;
|
|
34
37
|
/** Client-created operation UUID, retained for reconciliation after a lost response. */
|
|
35
38
|
readonly requestId: string;
|
|
36
39
|
readonly expectedRevision: string;
|
|
37
40
|
}
|
|
38
41
|
/** Optional plugin-owned task service, resolved from the current generation's owner scope. */
|
|
39
42
|
export interface PluginTaskProvider extends GenerationAdmissionSource {
|
|
43
|
+
readonly i18n?: Readonly<Partial<Record<Locale, Readonly<{
|
|
44
|
+
title?: string;
|
|
45
|
+
description?: string;
|
|
46
|
+
effects?: readonly string[];
|
|
47
|
+
}>>>>;
|
|
40
48
|
readonly title: string;
|
|
41
49
|
readonly description: string;
|
|
42
50
|
/** JSON Schema for this provider's creation/update parameters, never a Console-specific form. */
|
|
43
51
|
readonly schema: Readonly<Record<string, JsonValue>>;
|
|
44
52
|
/** Discloses future external effects before the operator creates a task. */
|
|
45
53
|
readonly effects: readonly string[];
|
|
46
|
-
read(
|
|
54
|
+
read(options?: Readonly<{
|
|
55
|
+
locale?: Locale;
|
|
56
|
+
}>): Promise<PluginTaskSnapshot>;
|
|
47
57
|
create(input: PluginTaskMutation & Readonly<{
|
|
48
58
|
target: DeliveryTarget;
|
|
49
59
|
parameters: Readonly<Record<string, JsonValue>>;
|
|
@@ -1,14 +1,19 @@
|
|
|
1
|
+
import type { Locale } from './i18n.js';
|
|
1
2
|
/** A deliberate business check. Effects must describe every write or external action. */
|
|
2
3
|
export interface PluginVerificationCheck {
|
|
3
4
|
readonly id: string;
|
|
4
5
|
readonly title: string;
|
|
6
|
+
readonly titleI18n?: Readonly<Partial<Record<Locale, string>>>;
|
|
5
7
|
readonly effects: readonly string[];
|
|
8
|
+
readonly effectsI18n?: Readonly<Partial<Record<Locale, readonly string[]>>>;
|
|
6
9
|
readonly scope: 'local' | 'platform';
|
|
7
10
|
}
|
|
8
11
|
export interface PluginVerificationOutcome {
|
|
9
12
|
readonly status: 'passed' | 'failed';
|
|
10
13
|
readonly summary: string;
|
|
14
|
+
readonly summaryI18n?: Readonly<Partial<Record<Locale, string>>>;
|
|
11
15
|
readonly evidence: readonly string[];
|
|
16
|
+
readonly evidenceI18n?: Readonly<Partial<Record<Locale, readonly string[]>>>;
|
|
12
17
|
}
|
|
13
18
|
/** Plugin-owned optional verification, distinct from generation publication/readiness. */
|
|
14
19
|
export interface PluginVerificationHost {
|
|
@@ -8,6 +8,8 @@ export interface RelationalStateCodec {
|
|
|
8
8
|
readonly definition: Record<string, unknown>;
|
|
9
9
|
};
|
|
10
10
|
readonly tables: Readonly<Record<string, Record<string, unknown>>>;
|
|
11
|
+
/** Explicit additions for an already persisted relational table. */
|
|
12
|
+
readonly addColumns?: Readonly<Record<string, readonly string[]>>;
|
|
11
13
|
encode(value: JsonValue): {
|
|
12
14
|
header: Record<string, unknown>;
|
|
13
15
|
rows: Record<string, readonly Record<string, unknown>[]>;
|
package/lib/relational-state.js
CHANGED
|
@@ -22,8 +22,14 @@ export function createRelationalStateStore(database, codecs) {
|
|
|
22
22
|
storage_present: { type: 'boolean', nullable: false },
|
|
23
23
|
storage_expires_at: { type: 'bigint', nullable: true },
|
|
24
24
|
});
|
|
25
|
-
for (const
|
|
26
|
-
|
|
25
|
+
for (const name of Object.keys(codec.addColumns ?? {})) {
|
|
26
|
+
if (!Object.hasOwn(codec.tables, name))
|
|
27
|
+
throw new TypeError(`Unknown relational migration table: ${name}`);
|
|
28
|
+
}
|
|
29
|
+
for (const [name, definition] of Object.entries(codec.tables)) {
|
|
30
|
+
const columns = codec.addColumns?.[name];
|
|
31
|
+
database.define(name, definition, columns ? { addColumns: columns } : undefined);
|
|
32
|
+
}
|
|
27
33
|
}
|
|
28
34
|
const migrated = new Set();
|
|
29
35
|
const writeRows = async (transaction, codec, rows) => {
|
|
@@ -36,6 +36,10 @@ function setValue(document, path, value) {
|
|
|
36
36
|
for (const [index, segment] of path.slice(0, -1).entries()) {
|
|
37
37
|
const existing = readChild(target, segment, path.slice(0, index + 1));
|
|
38
38
|
if (existing === undefined) {
|
|
39
|
+
// Inline guard (in addition to assertPath) so the write is visibly safe.
|
|
40
|
+
if (segment === '__proto__' || segment === 'constructor' || segment === 'prototype') {
|
|
41
|
+
throw unsafeSegment(segment);
|
|
42
|
+
}
|
|
39
43
|
const created = {};
|
|
40
44
|
target[segment] = created;
|
|
41
45
|
target = created;
|
|
@@ -59,7 +63,11 @@ function removeValue(document, path) {
|
|
|
59
63
|
target.splice(arrayIndex(segment, target, path), 1);
|
|
60
64
|
return;
|
|
61
65
|
}
|
|
62
|
-
|
|
66
|
+
if (segment === '__proto__' || segment === 'constructor' || segment === 'prototype') {
|
|
67
|
+
throw unsafeSegment(segment);
|
|
68
|
+
}
|
|
69
|
+
if (Object.hasOwn(target, segment))
|
|
70
|
+
delete target[segment];
|
|
63
71
|
}
|
|
64
72
|
function cloneDocument(value) {
|
|
65
73
|
const document = structuredClone(value);
|
|
@@ -71,13 +79,18 @@ function cloneDocument(value) {
|
|
|
71
79
|
function readChild(container, segment, path) {
|
|
72
80
|
if (Array.isArray(container))
|
|
73
81
|
return container[arrayIndex(segment, container, path)];
|
|
74
|
-
|
|
82
|
+
// Only own properties count: inherited members (toString, hasOwnProperty, ...)
|
|
83
|
+
// must never be traversed into or treated as existing config values.
|
|
84
|
+
return Object.hasOwn(container, segment) ? container[segment] : undefined;
|
|
75
85
|
}
|
|
76
86
|
function writeChild(container, segment, value, path) {
|
|
77
87
|
if (Array.isArray(container)) {
|
|
78
88
|
container[arrayIndex(segment, container, path)] = value;
|
|
79
89
|
return;
|
|
80
90
|
}
|
|
91
|
+
if (segment === '__proto__' || segment === 'constructor' || segment === 'prototype') {
|
|
92
|
+
throw unsafeSegment(segment);
|
|
93
|
+
}
|
|
81
94
|
container[segment] = value;
|
|
82
95
|
}
|
|
83
96
|
function arrayIndex(segment, container, path) {
|
|
@@ -99,10 +112,13 @@ function requireContainer(value, path) {
|
|
|
99
112
|
function assertPath(path) {
|
|
100
113
|
for (const segment of path) {
|
|
101
114
|
if (!segment || segment === '__proto__' || segment === 'prototype' || segment === 'constructor') {
|
|
102
|
-
throw
|
|
115
|
+
throw unsafeSegment(segment);
|
|
103
116
|
}
|
|
104
117
|
}
|
|
105
118
|
}
|
|
119
|
+
function unsafeSegment(segment) {
|
|
120
|
+
return new ConfigPatchPathError(`Unsafe config path segment: ${segment || '<empty>'}`);
|
|
121
|
+
}
|
|
106
122
|
function lastSegment(path) {
|
|
107
123
|
const segment = path[path.length - 1];
|
|
108
124
|
if (!segment)
|