esoul-sdk 0.3.0
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/README.md +61 -0
- package/bin/esoul-plugin.mjs +110 -0
- package/dist/files.d.ts +59 -0
- package/dist/files.js +39 -0
- package/dist/helpers.d.ts +40 -0
- package/dist/helpers.js +93 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +6 -0
- package/dist/manifest.d.ts +359 -0
- package/dist/manifest.js +136 -0
- package/dist/react.d.ts +85 -0
- package/dist/react.js +43 -0
- package/dist/server.d.ts +189 -0
- package/dist/server.js +58 -0
- package/dist/testing/index.d.ts +20 -0
- package/dist/testing/index.js +121 -0
- package/dist/types.d.ts +226 -0
- package/dist/types.js +6 -0
- package/docs/01-getting-started.md +114 -0
- package/docs/02-manifest.md +52 -0
- package/docs/03-events-and-state.md +92 -0
- package/docs/04-tools.md +85 -0
- package/docs/05-ui.md +86 -0
- package/docs/06-server.md +78 -0
- package/docs/07-background-tasks.md +61 -0
- package/docs/08-connections.md +49 -0
- package/docs/09-files.md +35 -0
- package/docs/10-testing.md +62 -0
- package/docs/11-shipping.md +70 -0
- package/docs/12-rules.md +37 -0
- package/llms-full.txt +934 -0
- package/llms.txt +23 -0
- package/package.json +80 -0
- package/schemas/plugin.schema.json +263 -0
- package/scripts/build-llms.mjs +36 -0
package/README.md
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# esoul-sdk
|
|
2
|
+
|
|
3
|
+
Build **ExternalSoul apps**: native apps for an event-sourced workspace where people and AI
|
|
4
|
+
agents share one canvas. An app you write with this SDK is indistinguishable from the platform's
|
|
5
|
+
own once it ships — the same events, the same timeline, the same durability, the same tools that
|
|
6
|
+
chat, voice, agents and MCP call.
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
npm install --save-dev esoul-sdk
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
Inside ExternalSoul the package name resolves to the platform's real implementations. Outside it
|
|
13
|
+
(your editor, your tests) the package gives you the types, the manifest validator, and the test
|
|
14
|
+
helpers; the server functions throw "host only" if called, because they run in the platform.
|
|
15
|
+
|
|
16
|
+
## Where to start
|
|
17
|
+
|
|
18
|
+
- **Build it in the Forge, not on your laptop.** Open a Forge board in your workspace and ask it
|
|
19
|
+
to open a workbench for your app. You get a cloud machine with the platform on it, a live
|
|
20
|
+
preview in your frame, your app's tools callable before it is installed, checks, and a submit
|
|
21
|
+
button. Read [docs/01-getting-started.md](docs/01-getting-started.md).
|
|
22
|
+
- **The contract, one page per part:**
|
|
23
|
+
1. [Getting started](docs/01-getting-started.md) — the loop, the package layout
|
|
24
|
+
2. [The manifest](docs/02-manifest.md) — `plugin.json`, every field
|
|
25
|
+
3. [Events and state](docs/03-events-and-state.md) — the heart: dataCreator, processor, replay
|
|
26
|
+
4. [Tools](docs/04-tools.md) — what agents call, on every surface
|
|
27
|
+
5. [The UI](docs/05-ui.md) — React, hooks, theme, responsive rules
|
|
28
|
+
6. [The server half](docs/06-server.md) — ops, webhooks, reading state, calling other apps
|
|
29
|
+
7. [Background tasks](docs/07-background-tasks.md) — durable work, polling, the replay model
|
|
30
|
+
8. [Connections and OAuth](docs/08-connections.md) — tokens the platform holds for you
|
|
31
|
+
9. [Files](docs/09-files.md) — workspace files, Drive, your own provider
|
|
32
|
+
10. [Testing](docs/10-testing.md) — the fold contract as tests
|
|
33
|
+
11. [Shipping](docs/11-shipping.md) — submit, review, release, install; the import wall
|
|
34
|
+
12. [Rules and failures](docs/12-rules.md) — every rule with the failure that earned it
|
|
35
|
+
- **For a coding model:** `llms.txt` (short) and `llms-full.txt` (the whole contract in one file).
|
|
36
|
+
|
|
37
|
+
## The one rule that explains the others
|
|
38
|
+
|
|
39
|
+
**Events are the truth.** Your app's state is the fold of its events, re-run on every replay,
|
|
40
|
+
scrub and sync. So a reducer must be pure and idempotent, ids and timestamps are minted in the
|
|
41
|
+
`dataCreator` (never in a reducer), whole-replace events carry a collapse key, and the agent-facing
|
|
42
|
+
state description never claims something it could not read. Everything in the docs follows from
|
|
43
|
+
that.
|
|
44
|
+
|
|
45
|
+
## What is in the package
|
|
46
|
+
|
|
47
|
+
| Entry | What it gives you |
|
|
48
|
+
|---|---|
|
|
49
|
+
| `esoul-sdk` | `ApplicationSchema`, `EventDefinition`, `EventTypes`, `ApplicationIdentifier`, `incompleteStateNotice`, `deterministicReducerId`, `stableStringify`, `timingSafeEqual`, `nanoid`, `callPluginOp`, the manifest schema |
|
|
50
|
+
| `esoul-sdk/react` | `usePluginEventDispatch`, `useAppCanEdit`, `usePluginCurrentChatId`, `useWorkspaceTools`, file hooks |
|
|
51
|
+
| `esoul-sdk/server` | `PluginServerModule`, `readAppState`, `callWorkspaceTool`, `emitPluginAppEvent`, `getPluginConnectionCredentials`, file provider types |
|
|
52
|
+
| `esoul-sdk/testing` | a mock OAuth server for connection tests |
|
|
53
|
+
| `esoul-app validate <dir>` | validates a package folder against the manifest schema |
|
|
54
|
+
|
|
55
|
+
## Versions
|
|
56
|
+
|
|
57
|
+
- **0.3.0** — renamed from `@externalsoul/plugin-sdk` (still resolved as an alias inside the
|
|
58
|
+
platform). `readAppState`, `callWorkspaceTool`, `nanoid` on the index. The import wall: an app
|
|
59
|
+
reaches the platform only through this package. Docs rewritten for the Forge workbench loop.
|
|
60
|
+
- 0.2.0 — file sources and providers.
|
|
61
|
+
- 0.1.0 — the contract: manifest, schema, events, tools, tasks, webhooks, ops, connections.
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* esoul-plugin — repo-free plugin package checks.
|
|
4
|
+
*
|
|
5
|
+
* esoul-app validate <dir> exit 0 = the folder is a well-formed
|
|
6
|
+
* package; exit 1 = every problem listed.
|
|
7
|
+
*
|
|
8
|
+
* This is the AUTHOR-SIDE half of validation (manifest shape, entry
|
|
9
|
+
* existence, forbidden imports, secret-shaped keys). The host's
|
|
10
|
+
* plugins:sync re-checks everything plus host-only facts (type collisions
|
|
11
|
+
* with installed apps) at install time — passing here is necessary, not
|
|
12
|
+
* sufficient, and the CLI says so.
|
|
13
|
+
*/
|
|
14
|
+
import fs from "node:fs";
|
|
15
|
+
import path from "node:path";
|
|
16
|
+
import url from "node:url";
|
|
17
|
+
|
|
18
|
+
const here = path.dirname(url.fileURLToPath(import.meta.url));
|
|
19
|
+
let PluginManifestSchema;
|
|
20
|
+
try {
|
|
21
|
+
({ PluginManifestSchema } = await import(
|
|
22
|
+
url.pathToFileURL(path.join(here, "..", "dist", "manifest.js")).href
|
|
23
|
+
));
|
|
24
|
+
} catch {
|
|
25
|
+
console.error("esoul-plugin: package not built (dist/ missing) — reinstall esoul-sdk");
|
|
26
|
+
process.exit(2);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const [cmd, dirArg] = process.argv.slice(2);
|
|
30
|
+
if (cmd !== "validate" || !dirArg) {
|
|
31
|
+
console.error("usage: esoul-app validate <pluginDir>");
|
|
32
|
+
process.exit(2);
|
|
33
|
+
}
|
|
34
|
+
const dir = path.resolve(dirArg);
|
|
35
|
+
const problems = [];
|
|
36
|
+
const problem = (m) => problems.push(m);
|
|
37
|
+
|
|
38
|
+
if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) {
|
|
39
|
+
console.error(`not a directory: ${dir}`);
|
|
40
|
+
process.exit(1);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/* manifest */
|
|
44
|
+
let manifest = null;
|
|
45
|
+
const manifestPath = path.join(dir, "plugin.json");
|
|
46
|
+
if (!fs.existsSync(manifestPath)) {
|
|
47
|
+
problem("plugin.json missing — not a plugin package");
|
|
48
|
+
} else {
|
|
49
|
+
try {
|
|
50
|
+
const raw = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
|
|
51
|
+
const parsed = PluginManifestSchema.safeParse(raw);
|
|
52
|
+
if (!parsed.success) {
|
|
53
|
+
for (const issue of parsed.error.issues) {
|
|
54
|
+
problem(`plugin.json ${issue.path.join(".") || "(root)"}: ${issue.message}`);
|
|
55
|
+
}
|
|
56
|
+
} else {
|
|
57
|
+
manifest = parsed.data;
|
|
58
|
+
}
|
|
59
|
+
} catch (e) {
|
|
60
|
+
problem(`plugin.json is not valid JSON: ${e.message}`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/* entry + server module */
|
|
65
|
+
if (manifest) {
|
|
66
|
+
const entryOk = ["tsx", "ts"].some((ext) =>
|
|
67
|
+
fs.existsSync(path.join(dir, `${manifest.entry}.${ext}`)),
|
|
68
|
+
);
|
|
69
|
+
if (!entryOk) problem(`entry module "${manifest.entry}.(tsx|ts)" not found`);
|
|
70
|
+
const needsServer = manifest.webhooks.length > 0 || manifest.ops.length > 0;
|
|
71
|
+
const hasServer = ["ts", "tsx"].some((ext) =>
|
|
72
|
+
fs.existsSync(path.join(dir, `server.${ext}`)),
|
|
73
|
+
);
|
|
74
|
+
if (needsServer && !hasServer) {
|
|
75
|
+
problem("declares webhooks/ops but has no server.ts exporting pluginServer");
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/* source lints (the init-cycle traps + secret smells) */
|
|
80
|
+
const walk = (d) =>
|
|
81
|
+
fs.readdirSync(d, { withFileTypes: true }).flatMap((e) => {
|
|
82
|
+
const p = path.join(d, e.name);
|
|
83
|
+
if (e.isDirectory()) {
|
|
84
|
+
return ["node_modules", ".git", "dist"].includes(e.name) ? [] : walk(p);
|
|
85
|
+
}
|
|
86
|
+
return /\.(ts|tsx)$/.test(e.name) ? [p] : [];
|
|
87
|
+
});
|
|
88
|
+
for (const f of walk(dir)) {
|
|
89
|
+
const src = fs.readFileSync(f, "utf8");
|
|
90
|
+
const rel = path.relative(dir, f);
|
|
91
|
+
if (/(from\s+|import\s*\(\s*|require\s*\(\s*)["'][^"']*client-registry["']/.test(src)) {
|
|
92
|
+
problem(`${rel}: imports client-registry — the init-cycle trap; plugins never import the registry`);
|
|
93
|
+
}
|
|
94
|
+
if (/from\s+["']@\/store\/venus-slice["']/.test(src)) {
|
|
95
|
+
problem(`${rel}: statically imports venus-slice — use the hooks from esoul-sdk/react`);
|
|
96
|
+
}
|
|
97
|
+
if (/(sk-[A-Za-z0-9]{20,}|-----BEGIN [A-Z ]*PRIVATE KEY-----)/.test(src)) {
|
|
98
|
+
problem(`${rel}: looks like a hard-coded secret — credentials belong in connections, never in code`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (problems.length) {
|
|
103
|
+
console.error(`✗ ${dir}`);
|
|
104
|
+
for (const p of problems) console.error(` - ${p}`);
|
|
105
|
+
process.exit(1);
|
|
106
|
+
}
|
|
107
|
+
console.log(`✓ ${manifest.name} (${manifest.id} / ${manifest.applicationType}) is a well-formed package.`);
|
|
108
|
+
console.log(
|
|
109
|
+
" Host-side checks (type collisions, gate tests) still run at install — this pass is necessary, not sufficient.",
|
|
110
|
+
);
|
package/dist/files.d.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File sources — the client-safe half of the files API
|
|
3
|
+
* (plugin-file-sources.md). PURE mirror of the host's
|
|
4
|
+
* src/lib/file-sources/types.ts; the sdk-drift gate keeps them in lockstep.
|
|
5
|
+
*
|
|
6
|
+
* A FileSource is a MOUNT: one place files come from (the workspace, the
|
|
7
|
+
* workspace's Google Drive, a local folder on the desktop build, or a mount
|
|
8
|
+
* contributed by a plugin's own connection). Identity = `sourceId` +
|
|
9
|
+
* provider-opaque `ref`; labels are display only. A source that cannot
|
|
10
|
+
* answer reports `source_unavailable` — never an empty listing.
|
|
11
|
+
*/
|
|
12
|
+
export type FileSourceId = string;
|
|
13
|
+
export interface FileSource {
|
|
14
|
+
sourceId: FileSourceId;
|
|
15
|
+
providerKey: string;
|
|
16
|
+
label: string;
|
|
17
|
+
detail?: string;
|
|
18
|
+
caps: {
|
|
19
|
+
write: boolean;
|
|
20
|
+
watch: boolean;
|
|
21
|
+
durable: boolean;
|
|
22
|
+
};
|
|
23
|
+
online: boolean;
|
|
24
|
+
}
|
|
25
|
+
export interface FileRef {
|
|
26
|
+
sourceId: FileSourceId;
|
|
27
|
+
/** Provider-opaque id. Workspace source: the WorkspaceFile.id uuid. */
|
|
28
|
+
ref: string;
|
|
29
|
+
}
|
|
30
|
+
export interface FileEntry {
|
|
31
|
+
ref: FileRef;
|
|
32
|
+
name: string;
|
|
33
|
+
kind: "file" | "folder";
|
|
34
|
+
sizeBytes?: number;
|
|
35
|
+
mimeType?: string;
|
|
36
|
+
/** Epoch ms. */
|
|
37
|
+
modifiedAt?: number;
|
|
38
|
+
path?: string;
|
|
39
|
+
}
|
|
40
|
+
export type FileSourceErrorKind = "not_declared" | "source_unavailable" | "not_found" | "too_large" | "read_only" | "disabled" | "bad_ref";
|
|
41
|
+
export declare class FileSourceError extends Error {
|
|
42
|
+
readonly kind: FileSourceErrorKind;
|
|
43
|
+
constructor(kind: FileSourceErrorKind, message: string);
|
|
44
|
+
}
|
|
45
|
+
export declare const FILE_READ_CAP_BYTES: number;
|
|
46
|
+
export declare const FILE_READ_HARD_CAP_BYTES: number;
|
|
47
|
+
export type ParsedSourceId = {
|
|
48
|
+
kind: "workspace";
|
|
49
|
+
} | {
|
|
50
|
+
kind: "google-drive";
|
|
51
|
+
} | {
|
|
52
|
+
kind: "local";
|
|
53
|
+
mountId: string;
|
|
54
|
+
} | {
|
|
55
|
+
kind: "plugin";
|
|
56
|
+
providerKey: string;
|
|
57
|
+
connectionId: string;
|
|
58
|
+
};
|
|
59
|
+
export declare function parseSourceId(sourceId: string): ParsedSourceId;
|
package/dist/files.js
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File sources — the client-safe half of the files API
|
|
3
|
+
* (plugin-file-sources.md). PURE mirror of the host's
|
|
4
|
+
* src/lib/file-sources/types.ts; the sdk-drift gate keeps them in lockstep.
|
|
5
|
+
*
|
|
6
|
+
* A FileSource is a MOUNT: one place files come from (the workspace, the
|
|
7
|
+
* workspace's Google Drive, a local folder on the desktop build, or a mount
|
|
8
|
+
* contributed by a plugin's own connection). Identity = `sourceId` +
|
|
9
|
+
* provider-opaque `ref`; labels are display only. A source that cannot
|
|
10
|
+
* answer reports `source_unavailable` — never an empty listing.
|
|
11
|
+
*/
|
|
12
|
+
export class FileSourceError extends Error {
|
|
13
|
+
kind;
|
|
14
|
+
constructor(kind, message) {
|
|
15
|
+
super(message);
|
|
16
|
+
this.name = "FileSourceError";
|
|
17
|
+
this.kind = kind;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
export const FILE_READ_CAP_BYTES = 25 * 1024 * 1024;
|
|
21
|
+
export const FILE_READ_HARD_CAP_BYTES = 100 * 1024 * 1024;
|
|
22
|
+
export function parseSourceId(sourceId) {
|
|
23
|
+
if (sourceId === "workspace")
|
|
24
|
+
return { kind: "workspace" };
|
|
25
|
+
if (sourceId === "google-drive")
|
|
26
|
+
return { kind: "google-drive" };
|
|
27
|
+
const colon = sourceId.indexOf(":");
|
|
28
|
+
if (colon <= 0 || colon === sourceId.length - 1) {
|
|
29
|
+
throw new FileSourceError("bad_ref", `unknown file source "${sourceId}"`);
|
|
30
|
+
}
|
|
31
|
+
const head = sourceId.slice(0, colon);
|
|
32
|
+
const tail = sourceId.slice(colon + 1);
|
|
33
|
+
if (head === "local")
|
|
34
|
+
return { kind: "local", mountId: tail };
|
|
35
|
+
if (head === "workspace" || head === "google-drive") {
|
|
36
|
+
throw new FileSourceError("bad_ref", `unknown file source "${sourceId}"`);
|
|
37
|
+
}
|
|
38
|
+
return { kind: "plugin", providerKey: head, connectionId: tail };
|
|
39
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure helpers — byte-for-byte vendored from the platform (a drift gate in
|
|
3
|
+
* the host asserts behavioural equality on every build).
|
|
4
|
+
*/
|
|
5
|
+
export declare function stableStringify(v: unknown): string;
|
|
6
|
+
/** Refold-stable id from event payload — the ONLY way a processor may mint
|
|
7
|
+
* an id when the dataCreator didn't (never nanoid/Date.now in a reducer). */
|
|
8
|
+
export declare function deterministicReducerId(prefix: string, seed: unknown): string;
|
|
9
|
+
/** Fields whose ABSENCE means "did not load" (they are seeded at genesis). */
|
|
10
|
+
export interface RequiredStateShape {
|
|
11
|
+
/** Keys that must be arrays. */
|
|
12
|
+
lists?: Record<string, unknown>;
|
|
13
|
+
/** Keys that must be non-null objects. */
|
|
14
|
+
maps?: Record<string, unknown>;
|
|
15
|
+
}
|
|
16
|
+
export declare function missingStateKeys(shape: RequiredStateShape): string[];
|
|
17
|
+
/**
|
|
18
|
+
* `null` when the state is whole — the describer proceeds normally.
|
|
19
|
+
* Otherwise the block to return INSTEAD of a description. A missing
|
|
20
|
+
* collection is "state did not load", NEVER "empty" — flooring to [] tells
|
|
21
|
+
* the model something false and invites it to rebuild over real data.
|
|
22
|
+
*/
|
|
23
|
+
export declare function incompleteStateNotice(args: {
|
|
24
|
+
title: string;
|
|
25
|
+
instanceName?: unknown;
|
|
26
|
+
shape: RequiredStateShape;
|
|
27
|
+
}): string | null;
|
|
28
|
+
/**
|
|
29
|
+
* Call one of your plugin's server ops from client-graph code (tools,
|
|
30
|
+
* tasks, UI). Server side authenticates with the internal secret; browser
|
|
31
|
+
* side rides the session. Throws the op's honest error — relay it, never
|
|
32
|
+
* swallow it.
|
|
33
|
+
*/
|
|
34
|
+
export declare function callPluginOp<T = unknown>(pluginId: string, op: string, nodeId: string, args?: unknown): Promise<T>;
|
|
35
|
+
/**
|
|
36
|
+
* Constant-time string compare for webhook secrets/signatures. A plain
|
|
37
|
+
* `===` leaks length/prefix timing; use THIS in every webhook handler.
|
|
38
|
+
* Pure JS (no node:crypto) so it is safe in any runtime the SDK reaches.
|
|
39
|
+
*/
|
|
40
|
+
export declare function timingSafeEqual(a: string, b: string): boolean;
|
package/dist/helpers.js
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure helpers — byte-for-byte vendored from the platform (a drift gate in
|
|
3
|
+
* the host asserts behavioural equality on every build).
|
|
4
|
+
*/
|
|
5
|
+
/* ── deterministic ids (from event-spec.ts) ─────────────────────────── */
|
|
6
|
+
export function stableStringify(v) {
|
|
7
|
+
if (v === null || typeof v !== "object")
|
|
8
|
+
return JSON.stringify(v) ?? "null";
|
|
9
|
+
if (Array.isArray(v))
|
|
10
|
+
return `[${v.map(stableStringify).join(",")}]`;
|
|
11
|
+
const keys = Object.keys(v).sort();
|
|
12
|
+
return `{${keys.map((k) => `${JSON.stringify(k)}:${stableStringify(v[k])}`).join(",")}}`;
|
|
13
|
+
}
|
|
14
|
+
/** Refold-stable id from event payload — the ONLY way a processor may mint
|
|
15
|
+
* an id when the dataCreator didn't (never nanoid/Date.now in a reducer). */
|
|
16
|
+
export function deterministicReducerId(prefix, seed) {
|
|
17
|
+
const s = stableStringify(seed);
|
|
18
|
+
let h = 5381;
|
|
19
|
+
for (let i = 0; i < s.length; i++)
|
|
20
|
+
h = ((h * 33) ^ s.charCodeAt(i)) >>> 0;
|
|
21
|
+
return `${prefix}_${h.toString(36)}${(s.length & 0xffff).toString(36)}`;
|
|
22
|
+
}
|
|
23
|
+
export function missingStateKeys(shape) {
|
|
24
|
+
const missing = [];
|
|
25
|
+
for (const [key, value] of Object.entries(shape.lists ?? {})) {
|
|
26
|
+
if (!Array.isArray(value))
|
|
27
|
+
missing.push(key);
|
|
28
|
+
}
|
|
29
|
+
for (const [key, value] of Object.entries(shape.maps ?? {})) {
|
|
30
|
+
if (value === null || value === undefined || typeof value !== "object") {
|
|
31
|
+
missing.push(key);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return missing;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* `null` when the state is whole — the describer proceeds normally.
|
|
38
|
+
* Otherwise the block to return INSTEAD of a description. A missing
|
|
39
|
+
* collection is "state did not load", NEVER "empty" — flooring to [] tells
|
|
40
|
+
* the model something false and invites it to rebuild over real data.
|
|
41
|
+
*/
|
|
42
|
+
export function incompleteStateNotice(args) {
|
|
43
|
+
const missing = missingStateKeys(args.shape);
|
|
44
|
+
if (missing.length === 0)
|
|
45
|
+
return null;
|
|
46
|
+
const name = typeof args.instanceName === "string" && args.instanceName
|
|
47
|
+
? args.instanceName
|
|
48
|
+
: "(unnamed)";
|
|
49
|
+
return (`## ${args.title} — "${name}"\n` +
|
|
50
|
+
`- ⚠ STATE UNAVAILABLE: ${missing.join(", ")} did not load, so this app's contents are UNKNOWN.\n` +
|
|
51
|
+
`- This is NOT an empty app. Do not add, overwrite, re-import or re-run anything here on the assumption that it is empty — read it with this app's own read/list tools first, or tell the user it could not be read.\n`);
|
|
52
|
+
}
|
|
53
|
+
/* ── ops caller (from lib/plugins/call-op.ts) ───────────────────────── */
|
|
54
|
+
/**
|
|
55
|
+
* Call one of your plugin's server ops from client-graph code (tools,
|
|
56
|
+
* tasks, UI). Server side authenticates with the internal secret; browser
|
|
57
|
+
* side rides the session. Throws the op's honest error — relay it, never
|
|
58
|
+
* swallow it.
|
|
59
|
+
*/
|
|
60
|
+
export async function callPluginOp(pluginId, op, nodeId, args) {
|
|
61
|
+
const serverBase = typeof window === "undefined"
|
|
62
|
+
? process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000"
|
|
63
|
+
: "";
|
|
64
|
+
const headers = { "Content-Type": "application/json" };
|
|
65
|
+
if (typeof window === "undefined" && process.env.INTERNAL_TOOL_SECRET) {
|
|
66
|
+
headers["x-esoul-internal-secret"] = process.env.INTERNAL_TOOL_SECRET;
|
|
67
|
+
}
|
|
68
|
+
const res = await fetch(`${serverBase}/api/plugins/${pluginId}/op/${op}`, {
|
|
69
|
+
method: "POST",
|
|
70
|
+
headers,
|
|
71
|
+
body: JSON.stringify({ nodeId, args }),
|
|
72
|
+
});
|
|
73
|
+
const body = (await res.json().catch(() => null));
|
|
74
|
+
if (!res.ok || !body?.ok) {
|
|
75
|
+
throw new Error(body?.error ?? `plugin op ${pluginId}/${op} failed (HTTP ${res.status})`);
|
|
76
|
+
}
|
|
77
|
+
return body.result;
|
|
78
|
+
}
|
|
79
|
+
/* ── webhook secret comparison ──────────────────────────────────────── */
|
|
80
|
+
/**
|
|
81
|
+
* Constant-time string compare for webhook secrets/signatures. A plain
|
|
82
|
+
* `===` leaks length/prefix timing; use THIS in every webhook handler.
|
|
83
|
+
* Pure JS (no node:crypto) so it is safe in any runtime the SDK reaches.
|
|
84
|
+
*/
|
|
85
|
+
export function timingSafeEqual(a, b) {
|
|
86
|
+
const len = Math.max(a.length, b.length, 1);
|
|
87
|
+
let diff = a.length === b.length ? 0 : 1;
|
|
88
|
+
for (let i = 0; i < len; i++) {
|
|
89
|
+
diff |= (a.charCodeAt(i % Math.max(a.length, 1)) || 0) ^
|
|
90
|
+
(b.charCodeAt(i % Math.max(b.length, 1)) || 0);
|
|
91
|
+
}
|
|
92
|
+
return diff === 0;
|
|
93
|
+
}
|
package/dist/index.d.ts
ADDED