esoul-sdk 0.24.0 → 0.25.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.
- package/CHANGELOG.md +58 -0
- package/README.md +23 -6
- package/api-reference.md +202 -18
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/machine/shell.js +2 -2
- package/dist/manifest.d.ts +1113 -31
- package/dist/manifest.js +175 -17
- package/dist/react.d.ts +13 -6
- package/dist/react.js +2 -2
- package/dist/server.d.ts +73 -9
- package/dist/server.js +16 -2
- package/dist/testing/credentials.d.ts +42 -0
- package/dist/testing/credentials.js +73 -0
- package/dist/testing/index.d.ts +2 -0
- package/dist/testing/index.js +1 -0
- package/dist/types.d.ts +15 -0
- package/docs/02-manifest.md +11 -3
- package/docs/04-tools.md +6 -0
- package/docs/05-ui.md +25 -0
- package/docs/06-server.md +55 -7
- package/docs/08-connections.md +322 -105
- package/docs/10-testing.md +29 -0
- package/docs/17-editing-and-merging.md +22 -0
- package/docs/18-your-computer.md +35 -13
- package/docs/20-a-service-on-your-computer.md +291 -0
- package/llms-full.txt +823 -134
- package/llms.txt +11 -8
- package/package.json +1 -1
- package/schemas/plugin.schema.json +240 -35
- package/scripts/build-llms.mjs +5 -3
package/dist/manifest.js
CHANGED
|
@@ -115,19 +115,26 @@ export const PluginConnectionSchema = z
|
|
|
115
115
|
.refine((c) => c.kind !== "oauth2" ||
|
|
116
116
|
(!!c.authorizeUrl && !!c.tokenUrl && !!c.clientIdEnv), { message: "oauth2 connections require authorizeUrl + tokenUrl + clientIdEnv" });
|
|
117
117
|
/**
|
|
118
|
-
*
|
|
118
|
+
* THE PERSON'S ACCOUNTS — used by an app without the app ever holding them.
|
|
119
119
|
*
|
|
120
|
-
* A person connects
|
|
121
|
-
*
|
|
122
|
-
*
|
|
123
|
-
*
|
|
124
|
-
*
|
|
125
|
-
* so the declared scopes are enforced at the proxy, not at Google.
|
|
120
|
+
* A person connects an account once, at the user level (Account settings → Google for Google,
|
|
121
|
+
* Account settings → Accounts for every other service), and assigns it to an app. The app's
|
|
122
|
+
* server code calls the service through `credentials(ctx).slot(name).fetch(url)` in
|
|
123
|
+
* `esoul-sdk/server`: the platform checks who is calling and that the URL is one the slot may
|
|
124
|
+
* reach, then adds the token or key itself. The secret never reaches the app or its developer.
|
|
126
125
|
*
|
|
127
|
-
*
|
|
128
|
-
* `
|
|
126
|
+
* Three families:
|
|
127
|
+
* - `google` — the platform's verified Google client. Scopes are Google's (`gmail.modify`);
|
|
128
|
+
* the platform's token cannot be narrowed, so the declared scopes are enforced at the proxy.
|
|
129
|
+
* - `oauth2` — any OAuth 2.0 provider (Microsoft, Dropbox, Spotify, …): the slot declares the
|
|
130
|
+
* provider's endpoints, the scopes it asks for and the API hosts the token may be sent to.
|
|
131
|
+
* - `apiKey` — a key the person pastes (an API key, a personal access token): the slot declares
|
|
132
|
+
* the hosts it may be sent to and the header that carries it.
|
|
133
|
+
*
|
|
134
|
+
* For `google` a scope is written as Google's full URL or as its short name (`gmail.modify`).
|
|
135
|
+
* `openid`, `email` and `profile` are implicit and may be left out.
|
|
129
136
|
*/
|
|
130
|
-
export const CREDENTIAL_FAMILIES = ["google"];
|
|
137
|
+
export const CREDENTIAL_FAMILIES = ["google", "oauth2", "apiKey"];
|
|
131
138
|
/** The Google scopes an app may declare, by short name. Everything else is refused. */
|
|
132
139
|
export const GOOGLE_CREDENTIAL_SCOPES = [
|
|
133
140
|
"gmail.readonly",
|
|
@@ -178,8 +185,8 @@ export function normaliseCredentialScopes(scopes) {
|
|
|
178
185
|
}
|
|
179
186
|
/** Slot names: lower-case words joined by `-` (`mailbox`, `work-calendar`). */
|
|
180
187
|
export const CREDENTIAL_SLOT_RE = /^[a-z][a-z0-9-]*$/;
|
|
181
|
-
/**
|
|
182
|
-
export const
|
|
188
|
+
/** A `google` slot of a manifest's `credentials` block. */
|
|
189
|
+
export const GoogleCredentialSlotSchema = z
|
|
183
190
|
.object({
|
|
184
191
|
family: z.literal("google").describe('Whose accounts: "google" is the platform\'s verified Google client.'),
|
|
185
192
|
scopes: z
|
|
@@ -206,6 +213,143 @@ export const PluginCredentialSlotSchema = z
|
|
|
206
213
|
background: z.boolean().optional().describe("True when the app's own tasks use the account with nobody present (a sync on a schedule)."),
|
|
207
214
|
})
|
|
208
215
|
.strict();
|
|
216
|
+
/**
|
|
217
|
+
* An API host an `oauth2` or `apiKey` secret may be sent to: a DNS name (`graph.microsoft.com`),
|
|
218
|
+
* lower-case, no scheme, no port, no IP address, no `localhost`.
|
|
219
|
+
*/
|
|
220
|
+
export const CREDENTIAL_HOST_RE = /^(?=.{4,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/;
|
|
221
|
+
const credentialHost = z
|
|
222
|
+
.string()
|
|
223
|
+
.regex(CREDENTIAL_HOST_RE, "a host is a DNS name like graph.microsoft.com — lower-case, no https://, no port, no path, no IP address");
|
|
224
|
+
const credentialHosts = z
|
|
225
|
+
.array(credentialHost)
|
|
226
|
+
.min(1)
|
|
227
|
+
.max(10)
|
|
228
|
+
.describe("The API hosts the secret may be sent to. The platform refuses every other host, and the person sees this list before connecting.");
|
|
229
|
+
/** Authorize parameters the platform sets itself; a slot may not override them. */
|
|
230
|
+
export const RESERVED_AUTHORIZE_PARAMS = ["response_type", "client_id", "redirect_uri", "scope", "state", "code_challenge", "code_challenge_method", "nonce"];
|
|
231
|
+
/** A provider scope string: printable, no spaces (`Mail.ReadWrite`, `offline_access`, `files.content.read`). */
|
|
232
|
+
const providerScope = z.string().min(1).max(200).regex(/^[\x21-\x7e]+$/, "a scope is one word without spaces");
|
|
233
|
+
/** The OAuth 2.0 provider an `oauth2` slot signs in with. */
|
|
234
|
+
export const OAuth2ProviderSchema = z
|
|
235
|
+
.object({
|
|
236
|
+
name: z.string().min(1).max(60).describe('The service as the person knows it: "Microsoft".'),
|
|
237
|
+
authorizeUrl: oauthEndpoint.describe("The provider's authorization endpoint (authorization code + PKCE)."),
|
|
238
|
+
tokenUrl: oauthEndpoint.describe("The provider's token endpoint. Accounts of one provider are the ones that share this URL and authorizeUrl."),
|
|
239
|
+
scopes: z.array(providerScope).min(1).max(40).describe("The provider's scopes this app asks for (e.g. offline_access, User.Read, Mail.ReadWrite)."),
|
|
240
|
+
hosts: credentialHosts,
|
|
241
|
+
accountUrl: oauthEndpoint
|
|
242
|
+
.optional()
|
|
243
|
+
.describe("Optional: a GET on one of `hosts` that answers JSON naming the signed-in account (e.g. https://graph.microsoft.com/v1.0/me)."),
|
|
244
|
+
accountField: z
|
|
245
|
+
.string()
|
|
246
|
+
.regex(/^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)*$/)
|
|
247
|
+
.optional()
|
|
248
|
+
.describe('The field of that JSON to show as the account, dotted for nesting (e.g. "userPrincipalName"). Default: email, mail, userPrincipalName, login, username, name — the first present.'),
|
|
249
|
+
authorizeParams: z
|
|
250
|
+
.record(z.string().regex(/^[a-z_][a-z0-9_]*$/), z.string().max(200))
|
|
251
|
+
.optional()
|
|
252
|
+
.describe('Extra authorize parameters (e.g. { "prompt": "select_account" }). The platform sets client_id, redirect_uri, scope, state and PKCE itself.'),
|
|
253
|
+
tokenAuth: z
|
|
254
|
+
.enum(["body", "basic"])
|
|
255
|
+
.optional()
|
|
256
|
+
.describe('How the client authenticates at tokenUrl: "body" (client_id + client_secret in the form, the default) or "basic" (HTTP Basic).'),
|
|
257
|
+
clientIdEnv: envName.optional().describe("Optional: a client the PLATFORM holds for this app (operator-set env). Without it, the person registers their own client once and enters it in Account settings."),
|
|
258
|
+
clientSecretEnv: envName.optional(),
|
|
259
|
+
})
|
|
260
|
+
.strict()
|
|
261
|
+
.superRefine((p, ctx) => {
|
|
262
|
+
for (const k of Object.keys(p.authorizeParams ?? {})) {
|
|
263
|
+
if (RESERVED_AUTHORIZE_PARAMS.includes(k)) {
|
|
264
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["authorizeParams", k], message: `"${k}" is set by the platform` });
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
if (p.accountUrl) {
|
|
268
|
+
let host = "";
|
|
269
|
+
try {
|
|
270
|
+
host = new URL(p.accountUrl).hostname.toLowerCase();
|
|
271
|
+
}
|
|
272
|
+
catch {
|
|
273
|
+
/* the field's own check reports it */
|
|
274
|
+
}
|
|
275
|
+
if (host && !p.hosts.includes(host)) {
|
|
276
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["accountUrl"], message: `accountUrl's host ${host} must be one of hosts` });
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
if (p.clientSecretEnv && !p.clientIdEnv) {
|
|
280
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["clientSecretEnv"], message: "clientSecretEnv needs clientIdEnv" });
|
|
281
|
+
}
|
|
282
|
+
});
|
|
283
|
+
/** Headers a key may never be sent in. */
|
|
284
|
+
const FORBIDDEN_KEY_HEADERS = new Set(["cookie", "host", "content-length", "content-type", "transfer-encoding", "connection", "proxy-authorization"]);
|
|
285
|
+
/** The service an `apiKey` slot sends a pasted key to. */
|
|
286
|
+
export const ApiKeyProviderSchema = z
|
|
287
|
+
.object({
|
|
288
|
+
name: z.string().min(1).max(60).describe('The service as the person knows it: "Notion".'),
|
|
289
|
+
hosts: credentialHosts,
|
|
290
|
+
header: z
|
|
291
|
+
.string()
|
|
292
|
+
.regex(/^[A-Za-z][A-Za-z0-9-]{0,63}$/)
|
|
293
|
+
.refine((h) => !FORBIDDEN_KEY_HEADERS.has(h.toLowerCase()), "that header cannot carry a key")
|
|
294
|
+
.optional()
|
|
295
|
+
.describe('The header that carries the key. Default "Authorization".'),
|
|
296
|
+
prefix: z
|
|
297
|
+
.string()
|
|
298
|
+
.max(20)
|
|
299
|
+
.regex(/^[\x20-\x7e]*$/)
|
|
300
|
+
.optional()
|
|
301
|
+
.describe('Written before the key in the header: "Bearer " (the default for Authorization), "Token ", or "" for none.'),
|
|
302
|
+
keyHelp: z.string().max(300).optional().describe("Where the person finds the key, in one sentence (shown beside the field)."),
|
|
303
|
+
verifyUrl: oauthEndpoint.optional().describe("Optional: a GET on one of `hosts` that answers 2xx for a valid key — the platform checks the key there before keeping it."),
|
|
304
|
+
})
|
|
305
|
+
.strict()
|
|
306
|
+
.superRefine((p, ctx) => {
|
|
307
|
+
if (p.verifyUrl) {
|
|
308
|
+
let host = "";
|
|
309
|
+
try {
|
|
310
|
+
host = new URL(p.verifyUrl).hostname.toLowerCase();
|
|
311
|
+
}
|
|
312
|
+
catch {
|
|
313
|
+
/* reported by the field */
|
|
314
|
+
}
|
|
315
|
+
if (host && !p.hosts.includes(host))
|
|
316
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["verifyUrl"], message: `verifyUrl's host ${host} must be one of hosts` });
|
|
317
|
+
}
|
|
318
|
+
});
|
|
319
|
+
const slotLabel = z.string().min(1).max(60).optional().describe("The slot's name on the screen (\"Mailbox\").");
|
|
320
|
+
const slotWhy = z.string().min(1).max(300).describe("One sentence the person reads before assigning an account: what the app does with it.");
|
|
321
|
+
const slotBackground = z.boolean().optional().describe("True when the app's own tasks use the account with nobody present (a sync on a schedule).");
|
|
322
|
+
/** An `oauth2` slot: any OAuth 2.0 provider. */
|
|
323
|
+
export const OAuth2CredentialSlotSchema = z
|
|
324
|
+
.object({ family: z.literal("oauth2"), provider: OAuth2ProviderSchema, label: slotLabel, why: slotWhy, background: slotBackground })
|
|
325
|
+
.strict();
|
|
326
|
+
/** An `apiKey` slot: a key the person pastes. */
|
|
327
|
+
export const ApiKeyCredentialSlotSchema = z
|
|
328
|
+
.object({ family: z.literal("apiKey"), provider: ApiKeyProviderSchema, label: slotLabel, why: slotWhy, background: slotBackground })
|
|
329
|
+
.strict();
|
|
330
|
+
/** One slot of a manifest's `credentials` block. */
|
|
331
|
+
export const PluginCredentialSlotSchema = z.discriminatedUnion("family", [GoogleCredentialSlotSchema, OAuth2CredentialSlotSchema, ApiKeyCredentialSlotSchema]);
|
|
332
|
+
/**
|
|
333
|
+
* Which accounts serve a slot: accounts of one provider are interchangeable between apps.
|
|
334
|
+
* `oauth2` — the sign-in AND token endpoints (lower-case host, path), so a slot that points either
|
|
335
|
+
* one somewhere else is another provider and never reuses a person's client or account;
|
|
336
|
+
* `apiKey` — the sorted host list. Pure, so the platform, the box and the SDK agree.
|
|
337
|
+
*/
|
|
338
|
+
export function credentialProviderKey(slot) {
|
|
339
|
+
if (slot.family === "oauth2" && slot.provider?.tokenUrl && slot.provider?.authorizeUrl) {
|
|
340
|
+
try {
|
|
341
|
+
const t = new URL(slot.provider.tokenUrl);
|
|
342
|
+
const a = new URL(slot.provider.authorizeUrl);
|
|
343
|
+
return `oauth2:${t.protocol}//${t.host.toLowerCase()}${t.pathname}|${a.protocol}//${a.host.toLowerCase()}${a.pathname}`;
|
|
344
|
+
}
|
|
345
|
+
catch {
|
|
346
|
+
return null;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
if (slot.family === "apiKey" && slot.provider?.hosts?.length)
|
|
350
|
+
return `apiKey:${[...slot.provider.hosts].map((h) => h.toLowerCase()).sort().join(",")}`;
|
|
351
|
+
return null;
|
|
352
|
+
}
|
|
209
353
|
/** The `credentials` block: slot name → slot. At most ONE slot per app for now. */
|
|
210
354
|
export const PluginCredentialsSchema = z
|
|
211
355
|
.record(z.string().regex(CREDENTIAL_SLOT_RE, "a slot name is lower-case words joined by - (e.g. mailbox)"), PluginCredentialSlotSchema)
|
|
@@ -478,7 +622,7 @@ export const PluginManifestSchema = z.object({
|
|
|
478
622
|
* Parts of the app that run ON THE PERSON'S COMPUTER (plugin-device-arm.md
|
|
479
623
|
* §10–§11). The declared commands are the whole authority: the owner
|
|
480
624
|
* approves this list by name when they connect a machine, the app calls
|
|
481
|
-
* them with `
|
|
625
|
+
* them with `devices(ctx).run(name, params, { linkId })`, and nothing else can
|
|
482
626
|
* run. Adding or changing a command in an update waits for a fresh approval.
|
|
483
627
|
*/
|
|
484
628
|
device: z
|
|
@@ -548,10 +692,12 @@ export const PluginManifestSchema = z.object({
|
|
|
548
692
|
/** Third-party logins surfaced in Account settings → Plugins. */
|
|
549
693
|
connections: z.array(PluginConnectionSchema).optional().default([]),
|
|
550
694
|
/**
|
|
551
|
-
*
|
|
552
|
-
*
|
|
553
|
-
*
|
|
554
|
-
*
|
|
695
|
+
* THE PERSON'S ACCOUNTS the app uses (docs/08): slot name → `{ family: "google", scopes, why }`,
|
|
696
|
+
* `{ family: "oauth2", provider: { name, authorizeUrl, tokenUrl, scopes, hosts }, why }` or
|
|
697
|
+
* `{ family: "apiKey", provider: { name, hosts, header?, prefix? }, why }` (+ `label?`,
|
|
698
|
+
* `background?`). The person connects an account once in Account settings and assigns it to the
|
|
699
|
+
* app; the app calls through `credentials(ctx).slot(name).fetch(url)` and never sees a token or
|
|
700
|
+
* key. At most one slot per app for now.
|
|
555
701
|
*/
|
|
556
702
|
credentials: PluginCredentialsSchema.optional(),
|
|
557
703
|
/**
|
|
@@ -722,4 +868,16 @@ export const PluginManifestSchema = z.object({
|
|
|
722
868
|
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["connections", i, field], message: problem });
|
|
723
869
|
}
|
|
724
870
|
});
|
|
871
|
+
for (const [slot, decl] of Object.entries(m.credentials ?? {})) {
|
|
872
|
+
if (decl.family !== "oauth2")
|
|
873
|
+
continue;
|
|
874
|
+
for (const field of ["clientIdEnv", "clientSecretEnv"]) {
|
|
875
|
+
const name = decl.provider[field];
|
|
876
|
+
if (name === undefined || !PLUGIN_ENV_NAME_RE.test(name))
|
|
877
|
+
continue;
|
|
878
|
+
const problem = pluginEnvNameProblem(m.id, name);
|
|
879
|
+
if (problem)
|
|
880
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["credentials", slot, "provider", field], message: problem });
|
|
881
|
+
}
|
|
882
|
+
}
|
|
725
883
|
});
|
package/dist/react.d.ts
CHANGED
|
@@ -535,13 +535,20 @@ export interface UseCredential {
|
|
|
535
535
|
account?: {
|
|
536
536
|
email: string | null;
|
|
537
537
|
};
|
|
538
|
-
/**
|
|
538
|
+
/** Whose account: `{ name: "Google", family: "google" }`, `{ name: "Microsoft", family: "oauth2" }`, … */
|
|
539
|
+
provider?: {
|
|
540
|
+
name: string;
|
|
541
|
+
family: "google" | "oauth2" | "apiKey";
|
|
542
|
+
};
|
|
543
|
+
/** Declared scopes the account has not granted. */
|
|
539
544
|
missingScopes?: string[];
|
|
545
|
+
/** oauth2 / apiKey: declared hosts the account was not connected for. */
|
|
546
|
+
missingHosts?: string[];
|
|
540
547
|
reason?: string;
|
|
541
548
|
/**
|
|
542
|
-
* Take the person where the state can be fixed: the
|
|
543
|
-
*
|
|
544
|
-
* only the account's owner could act.
|
|
549
|
+
* Take the person where the state can be fixed: the provider's consent for missing permissions
|
|
550
|
+
* or a reconnect, Account settings (→ Google, or → Accounts for every other service) to assign
|
|
551
|
+
* or connect an account. Does nothing when only the account's owner could act.
|
|
545
552
|
*/
|
|
546
553
|
connect(): void;
|
|
547
554
|
/** Ask again. Also done when the window regains focus. */
|
|
@@ -550,8 +557,8 @@ export interface UseCredential {
|
|
|
550
557
|
/**
|
|
551
558
|
* The state of one of your plugin.json `credentials` slots for the person looking at the app.
|
|
552
559
|
*
|
|
553
|
-
* const
|
|
554
|
-
* if (
|
|
560
|
+
* const ms = useCredential("microsoft", { nodeId: identifier.nodeId });
|
|
561
|
+
* if (ms.state !== "ready") return <button onClick={ms.connect}>Connect {ms.provider?.name}</button>;
|
|
555
562
|
*/
|
|
556
563
|
export declare function useCredential(_slot: string, _where: {
|
|
557
564
|
nodeId: string;
|
package/dist/react.js
CHANGED
|
@@ -211,8 +211,8 @@ export function YourComputers(_props) {
|
|
|
211
211
|
/**
|
|
212
212
|
* The state of one of your plugin.json `credentials` slots for the person looking at the app.
|
|
213
213
|
*
|
|
214
|
-
* const
|
|
215
|
-
* if (
|
|
214
|
+
* const ms = useCredential("microsoft", { nodeId: identifier.nodeId });
|
|
215
|
+
* if (ms.state !== "ready") return <button onClick={ms.connect}>Connect {ms.provider?.name}</button>;
|
|
216
216
|
*/
|
|
217
217
|
export function useCredential(_slot, _where) {
|
|
218
218
|
return hostOnly("useCredential");
|
package/dist/server.d.ts
CHANGED
|
@@ -234,9 +234,9 @@ export type PluginConnectionCredentials = {
|
|
|
234
234
|
export declare function getPluginConnectionCredentials(_connectionId: string, _pluginId: string): Promise<PluginConnectionCredentials>;
|
|
235
235
|
/** Why a credential call was refused. */
|
|
236
236
|
export type CredentialUnavailableCode =
|
|
237
|
-
/** No account is assigned to this app's slot. The owner assigns one in Account settings. */
|
|
237
|
+
/** No account is assigned to this app's slot. The owner assigns one in Account settings (→ Google, or → Accounts). */
|
|
238
238
|
"not_bound"
|
|
239
|
-
/** The account has not granted every scope the slot declares; `connectUrl` asks
|
|
239
|
+
/** The account has not granted every scope the slot declares, or was not connected for every host it declares; `connectUrl` asks. */
|
|
240
240
|
| "needs_consent"
|
|
241
241
|
/** The account's grant was revoked or expired; the owner connects it again (`connectUrl`). */
|
|
242
242
|
| "reconnect"
|
|
@@ -254,8 +254,15 @@ export interface CredentialStatus {
|
|
|
254
254
|
account?: {
|
|
255
255
|
email: string | null;
|
|
256
256
|
};
|
|
257
|
-
/**
|
|
257
|
+
/** Whose account: `{ name: "Google", family: "google" }`, `{ name: "Microsoft", family: "oauth2" }`, … */
|
|
258
|
+
provider?: {
|
|
259
|
+
name: string;
|
|
260
|
+
family: "google" | "oauth2" | "apiKey";
|
|
261
|
+
};
|
|
262
|
+
/** Declared scopes the account has not granted (full Google URLs for google; the provider's own words otherwise). */
|
|
258
263
|
missingScopes?: string[];
|
|
264
|
+
/** oauth2 / apiKey: declared hosts the account was not connected for (the person approves them). */
|
|
265
|
+
missingHosts?: string[];
|
|
259
266
|
/** Where the person grants what is missing: a platform URL, never a provider one. */
|
|
260
267
|
connectUrl?: string;
|
|
261
268
|
/** One sentence for the screen or the log, when the state is not `ready`. */
|
|
@@ -295,10 +302,12 @@ export interface CredentialSlot {
|
|
|
295
302
|
/** Is the slot usable by THIS caller right now, and if not, why. Never throws for a known slot. */
|
|
296
303
|
status(): Promise<CredentialStatus>;
|
|
297
304
|
/**
|
|
298
|
-
* `fetch`, with the account's token added by the platform. Only https URLs the slot
|
|
299
|
-
*
|
|
300
|
-
*
|
|
301
|
-
*
|
|
305
|
+
* `fetch`, with the account's token or key added by the platform. Only https URLs the slot may
|
|
306
|
+
* reach are sent: for `google` the endpoints its declared scopes reach (e.g.
|
|
307
|
+
* `https://gmail.googleapis.com/gmail/v1/users/me/…`); for `oauth2` / `apiKey` the hosts its
|
|
308
|
+
* provider block declares (e.g. `https://graph.microsoft.com/v1.0/me/messages`). Any
|
|
309
|
+
* `Authorization`, `Cookie` or key header you pass is dropped. Answers the provider's
|
|
310
|
+
* `Response` (bodies up to 35 MB). Throws `CredentialUnavailable` when the call cannot be made.
|
|
302
311
|
*/
|
|
303
312
|
fetch(url: string | URL, init?: RequestInit): Promise<Response>;
|
|
304
313
|
}
|
|
@@ -315,9 +324,14 @@ export interface Credentials {
|
|
|
315
324
|
* const g = credentials(ctx).slot("mailbox");
|
|
316
325
|
* const r = await g.fetch("https://gmail.googleapis.com/gmail/v1/users/me/labels");
|
|
317
326
|
*
|
|
327
|
+
* const ms = credentials(ctx).slot("microsoft"); // family "oauth2"
|
|
328
|
+
* const me = await (await ms.fetch("https://graph.microsoft.com/v1.0/me/messages?$top=10")).json();
|
|
329
|
+
*
|
|
318
330
|
* Only the account's owner (or the app's own background work, when the slot declares
|
|
319
|
-
* `background: true`) may use it; anyone else gets `not_allowed`. HOST-ONLY
|
|
320
|
-
*
|
|
331
|
+
* `background: true`) may use it; anyone else gets `not_allowed`. HOST-ONLY. In a Forge preview a
|
|
332
|
+
* `google` slot talks to a simulated Gmail; an `oauth2` / `apiKey` slot answers `not_bound`
|
|
333
|
+
* (connect real accounts to the INSTALLED app) — test that code with `fakeCredentials` from
|
|
334
|
+
* `esoul-sdk/testing`.
|
|
321
335
|
*/
|
|
322
336
|
export declare function credentials(_ctx: object): Credentials;
|
|
323
337
|
/** One turn of the conversation the model reads. */
|
|
@@ -667,6 +681,56 @@ export declare function mintRouteToken(_ctx: {
|
|
|
667
681
|
ttlSeconds?: number;
|
|
668
682
|
label?: string;
|
|
669
683
|
}): Promise<RouteTokenGrant>;
|
|
684
|
+
/**
|
|
685
|
+
* The headless bridge — a page opened in a headless browser ON THE PLATFORM and driven over
|
|
686
|
+
* `postMessage`, for an app whose engine only exists as a web page (a WebAssembly CAD kernel, a
|
|
687
|
+
* canvas renderer). "No computer should be required": the person's tab is one place the page
|
|
688
|
+
* can run; this is the other, on Vercel, with nobody's tab open.
|
|
689
|
+
*
|
|
690
|
+
* `url` must be https and public. The page is loaded top-level, so it is its own `parent`: a
|
|
691
|
+
* bridge written for an iframe (`?parent=<origin>` + `window.parent.postMessage`) answers
|
|
692
|
+
* unchanged when the url names the page's own origin as the parent. `boot` waits for the page's
|
|
693
|
+
* ready message; each call posts `message` to the page's window and returns the first message
|
|
694
|
+
* whose `[replyField]` (default "id") equals the call's `id`. The page stays warm on the platform
|
|
695
|
+
* between calls for the same url — the first call pays the launch, later ones only the calls.
|
|
696
|
+
* Not available in a Forge preview (the box has no browser): keep the app open there instead.
|
|
697
|
+
*/
|
|
698
|
+
export interface HeadlessBridgeCall {
|
|
699
|
+
id: string;
|
|
700
|
+
message: Record<string, unknown>;
|
|
701
|
+
replyField?: string;
|
|
702
|
+
timeoutMs?: number;
|
|
703
|
+
}
|
|
704
|
+
export interface HeadlessBridgeArgs {
|
|
705
|
+
url: string;
|
|
706
|
+
boot?: {
|
|
707
|
+
field: string;
|
|
708
|
+
oneOf: string[];
|
|
709
|
+
timeoutMs?: number;
|
|
710
|
+
};
|
|
711
|
+
calls: HeadlessBridgeCall[];
|
|
712
|
+
viewport?: {
|
|
713
|
+
width: number;
|
|
714
|
+
height: number;
|
|
715
|
+
};
|
|
716
|
+
}
|
|
717
|
+
export interface HeadlessBridgeResult {
|
|
718
|
+
boot: Record<string, unknown> | null;
|
|
719
|
+
replies: (Record<string, unknown> | null)[];
|
|
720
|
+
timings: Record<string, number>;
|
|
721
|
+
warm: boolean;
|
|
722
|
+
}
|
|
723
|
+
/**
|
|
724
|
+
* Run a web page in the platform's headless browser and drive it over `postMessage` — see the
|
|
725
|
+
* `HeadlessBridgeArgs` note above: `url` (https, public), `boot` (the page's ready message),
|
|
726
|
+
* `calls` (each posted to the page's window, answered by the first message whose `[replyField]`
|
|
727
|
+
* equals its `id`). Warm between calls for the same url; not available in a Forge box.
|
|
728
|
+
*/
|
|
729
|
+
export declare function headlessBridge(_ctx: {
|
|
730
|
+
pluginId: string;
|
|
731
|
+
nodeId: string;
|
|
732
|
+
origin?: string;
|
|
733
|
+
}, _args: HeadlessBridgeArgs): Promise<HeadlessBridgeResult>;
|
|
670
734
|
/** A question for the person (questions(ctx).ask). */
|
|
671
735
|
export interface AppQuestion {
|
|
672
736
|
/** Stable per question within this app instance: asking twice with one key is one question. */
|
package/dist/server.js
CHANGED
|
@@ -70,9 +70,14 @@ export function isCredentialUnavailable(err) {
|
|
|
70
70
|
* const g = credentials(ctx).slot("mailbox");
|
|
71
71
|
* const r = await g.fetch("https://gmail.googleapis.com/gmail/v1/users/me/labels");
|
|
72
72
|
*
|
|
73
|
+
* const ms = credentials(ctx).slot("microsoft"); // family "oauth2"
|
|
74
|
+
* const me = await (await ms.fetch("https://graph.microsoft.com/v1.0/me/messages?$top=10")).json();
|
|
75
|
+
*
|
|
73
76
|
* Only the account's owner (or the app's own background work, when the slot declares
|
|
74
|
-
* `background: true`) may use it; anyone else gets `not_allowed`. HOST-ONLY
|
|
75
|
-
*
|
|
77
|
+
* `background: true`) may use it; anyone else gets `not_allowed`. HOST-ONLY. In a Forge preview a
|
|
78
|
+
* `google` slot talks to a simulated Gmail; an `oauth2` / `apiKey` slot answers `not_bound`
|
|
79
|
+
* (connect real accounts to the INSTALLED app) — test that code with `fakeCredentials` from
|
|
80
|
+
* `esoul-sdk/testing`.
|
|
76
81
|
*/
|
|
77
82
|
export function credentials(_ctx) {
|
|
78
83
|
return hostOnly("credentials");
|
|
@@ -261,6 +266,15 @@ export function renderChartImage(_ctx, _args) {
|
|
|
261
266
|
export function mintRouteToken(_ctx, _args) {
|
|
262
267
|
return hostOnly("mintRouteToken");
|
|
263
268
|
}
|
|
269
|
+
/**
|
|
270
|
+
* Run a web page in the platform's headless browser and drive it over `postMessage` — see the
|
|
271
|
+
* `HeadlessBridgeArgs` note above: `url` (https, public), `boot` (the page's ready message),
|
|
272
|
+
* `calls` (each posted to the page's window, answered by the first message whose `[replyField]`
|
|
273
|
+
* equals its `id`). Warm between calls for the same url; not available in a Forge box.
|
|
274
|
+
*/
|
|
275
|
+
export function headlessBridge(_ctx, _args) {
|
|
276
|
+
return hostOnly("headlessBridge");
|
|
277
|
+
}
|
|
264
278
|
/**
|
|
265
279
|
* THE PLATFORM'S QUESTIONS BELL. A question an app needs the person to answer
|
|
266
280
|
* (an agent waiting on a decision) reaches them wherever they are — the bell,
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `fakeCredentials()` — a stand-in for `credentials(ctx)` (docs/08) with the platform's rules and
|
|
3
|
+
* no accounts: your test says what each slot answers, and every call is recorded.
|
|
4
|
+
*
|
|
5
|
+
* const creds = fakeCredentials({
|
|
6
|
+
* microsoft: {
|
|
7
|
+
* hosts: ["graph.microsoft.com"],
|
|
8
|
+
* fetch: (url) => Response.json(url.pathname.endsWith("/me") ? { userPrincipalName: "ann@contoso.com" } : { value: [] }),
|
|
9
|
+
* },
|
|
10
|
+
* });
|
|
11
|
+
* jest.mock("esoul-sdk/server", () => ({ ...jest.requireActual("esoul-sdk/server"), credentials: (ctx: object) => creds.credentials(ctx) }));
|
|
12
|
+
* // or, for a task: ctx.credentials = (slot) => creds.credentials(ctx).slot(slot)
|
|
13
|
+
* creds.calls; // [{ slot, method, url, headers }] — headers as your code sent them, minus the stripped ones
|
|
14
|
+
*
|
|
15
|
+
* Like the platform: only https URLs on the slot's `hosts` are let through (when `hosts` is
|
|
16
|
+
* given), `Authorization` / `Cookie` your code sets are dropped, an undeclared slot throws
|
|
17
|
+
* `not_declared`, and a slot whose `status` is not ready throws its state as the code.
|
|
18
|
+
*/
|
|
19
|
+
import { type CredentialStatus, type Credentials } from "../server.js";
|
|
20
|
+
export interface FakeCredentialSlot {
|
|
21
|
+
/** What `status()` answers. Default `{ state: "ready" }`; any other state makes `fetch` throw it. */
|
|
22
|
+
status?: CredentialStatus;
|
|
23
|
+
/** Hosts the slot may reach (an oauth2 / apiKey slot's `provider.hosts`). Absent = any https host. */
|
|
24
|
+
hosts?: string[];
|
|
25
|
+
/** Answers a call that passed the rules. Default: 404 with an empty JSON body. */
|
|
26
|
+
fetch?: (url: URL, init: RequestInit) => Response | Promise<Response>;
|
|
27
|
+
}
|
|
28
|
+
export interface FakeCredentialCall {
|
|
29
|
+
slot: string;
|
|
30
|
+
method: string;
|
|
31
|
+
url: string;
|
|
32
|
+
headers: Record<string, string>;
|
|
33
|
+
}
|
|
34
|
+
export interface FakeCredentials {
|
|
35
|
+
credentials(ctx: object): Credentials;
|
|
36
|
+
calls: FakeCredentialCall[];
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* A stand-in for `credentials(ctx)` from `esoul-sdk/server`: each slot answers as the test says
|
|
40
|
+
* (`status`, `hosts`, `fetch`), the platform's refusals apply, and `calls` records every request.
|
|
41
|
+
*/
|
|
42
|
+
export declare function fakeCredentials(slots: Record<string, FakeCredentialSlot>): FakeCredentials;
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `fakeCredentials()` — a stand-in for `credentials(ctx)` (docs/08) with the platform's rules and
|
|
3
|
+
* no accounts: your test says what each slot answers, and every call is recorded.
|
|
4
|
+
*
|
|
5
|
+
* const creds = fakeCredentials({
|
|
6
|
+
* microsoft: {
|
|
7
|
+
* hosts: ["graph.microsoft.com"],
|
|
8
|
+
* fetch: (url) => Response.json(url.pathname.endsWith("/me") ? { userPrincipalName: "ann@contoso.com" } : { value: [] }),
|
|
9
|
+
* },
|
|
10
|
+
* });
|
|
11
|
+
* jest.mock("esoul-sdk/server", () => ({ ...jest.requireActual("esoul-sdk/server"), credentials: (ctx: object) => creds.credentials(ctx) }));
|
|
12
|
+
* // or, for a task: ctx.credentials = (slot) => creds.credentials(ctx).slot(slot)
|
|
13
|
+
* creds.calls; // [{ slot, method, url, headers }] — headers as your code sent them, minus the stripped ones
|
|
14
|
+
*
|
|
15
|
+
* Like the platform: only https URLs on the slot's `hosts` are let through (when `hosts` is
|
|
16
|
+
* given), `Authorization` / `Cookie` your code sets are dropped, an undeclared slot throws
|
|
17
|
+
* `not_declared`, and a slot whose `status` is not ready throws its state as the code.
|
|
18
|
+
*/
|
|
19
|
+
import { CredentialUnavailable } from "../server.js";
|
|
20
|
+
const STRIPPED = new Set(["authorization", "cookie"]);
|
|
21
|
+
/**
|
|
22
|
+
* A stand-in for `credentials(ctx)` from `esoul-sdk/server`: each slot answers as the test says
|
|
23
|
+
* (`status`, `hosts`, `fetch`), the platform's refusals apply, and `calls` records every request.
|
|
24
|
+
*/
|
|
25
|
+
export function fakeCredentials(slots) {
|
|
26
|
+
const calls = [];
|
|
27
|
+
const slotOf = (name) => {
|
|
28
|
+
const s = slots[name];
|
|
29
|
+
if (!s)
|
|
30
|
+
throw new CredentialUnavailable("not_declared", `this app's plugin.json declares no credential slot "${name}"`);
|
|
31
|
+
return s;
|
|
32
|
+
};
|
|
33
|
+
return {
|
|
34
|
+
calls,
|
|
35
|
+
credentials(_ctx) {
|
|
36
|
+
return {
|
|
37
|
+
slot(name) {
|
|
38
|
+
return {
|
|
39
|
+
status: async () => slotOf(name).status ?? { state: "ready" },
|
|
40
|
+
fetch: async (url, init = {}) => {
|
|
41
|
+
const s = slotOf(name);
|
|
42
|
+
const st = s.status ?? { state: "ready" };
|
|
43
|
+
if (st.state !== "ready")
|
|
44
|
+
throw new CredentialUnavailable(st.state, st.reason ?? st.state, { connectUrl: st.connectUrl, missingScopes: st.missingScopes });
|
|
45
|
+
let u;
|
|
46
|
+
try {
|
|
47
|
+
u = new URL(typeof url === "string" ? url : url.href);
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
throw new CredentialUnavailable("not_allowed", "refused: the URL does not parse");
|
|
51
|
+
}
|
|
52
|
+
if (u.protocol !== "https:" || u.username || u.password || u.port)
|
|
53
|
+
throw new CredentialUnavailable("not_allowed", "refused: only plain https:// URLs are reachable");
|
|
54
|
+
if (s.hosts && !s.hosts.map((h) => h.toLowerCase()).includes(u.hostname.toLowerCase())) {
|
|
55
|
+
throw new CredentialUnavailable("not_allowed", `refused: ${u.hostname} is not a host this account may be sent to (${s.hosts.join(", ")})`);
|
|
56
|
+
}
|
|
57
|
+
const headers = {};
|
|
58
|
+
new Headers(init.headers ?? undefined).forEach((v, k) => {
|
|
59
|
+
if (!STRIPPED.has(k.toLowerCase()))
|
|
60
|
+
headers[k] = v;
|
|
61
|
+
});
|
|
62
|
+
const method = (init.method ?? "GET").toUpperCase();
|
|
63
|
+
calls.push({ slot: name, method, url: u.href, headers });
|
|
64
|
+
if (s.fetch)
|
|
65
|
+
return s.fetch(u, { ...init, method, headers });
|
|
66
|
+
return new Response("{}", { status: 404, headers: { "content-type": "application/json" } });
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
}
|
package/dist/testing/index.d.ts
CHANGED
|
@@ -18,6 +18,8 @@ export { capture, fakeApps, runOp } from "./ops.js";
|
|
|
18
18
|
export type { BoundAppFixture, EmitCall, NotifyCall, Recorder, RunOpOptions, RunOpResult } from "./ops.js";
|
|
19
19
|
export { memoryFiles } from "./files.js";
|
|
20
20
|
export { scriptedModel, defaultScriptedText } from "./llm.js";
|
|
21
|
+
export { fakeCredentials } from "./credentials.js";
|
|
22
|
+
export type { FakeCredentials, FakeCredentialSlot, FakeCredentialCall } from "./credentials.js";
|
|
21
23
|
/** The failed-request banner's list, for a test that asserts a click reverted and was named (runOptimistic). */
|
|
22
24
|
export { listFailedRequests, subscribeFailedRequests, __resetFailedRequests as resetFailedRequests } from "../failed-requests.js";
|
|
23
25
|
export type { ScriptedLlm, ScriptedPolicy, ScriptedReply } from "./llm.js";
|
package/dist/testing/index.js
CHANGED
|
@@ -16,6 +16,7 @@ export { fakeViewer, memoryDb, roleForKind } from "./db.js";
|
|
|
16
16
|
export { capture, fakeApps, runOp } from "./ops.js";
|
|
17
17
|
export { memoryFiles } from "./files.js";
|
|
18
18
|
export { scriptedModel, defaultScriptedText } from "./llm.js";
|
|
19
|
+
export { fakeCredentials } from "./credentials.js";
|
|
19
20
|
/** The failed-request banner's list, for a test that asserts a click reverted and was named (runOptimistic). */
|
|
20
21
|
export { listFailedRequests, subscribeFailedRequests, __resetFailedRequests as resetFailedRequests } from "../failed-requests.js";
|
|
21
22
|
export { simGmail, DEFAULT_SIM_ACCOUNT, DEFAULT_CROWD_MIX } from "./sim-gmail/index.js";
|
package/dist/types.d.ts
CHANGED
|
@@ -140,6 +140,21 @@ export interface EventDefinition<StateType extends ApplicationIdentifier> {
|
|
|
140
140
|
* back to the normal conflict record. Pure. See docs/17-editing-and-merging.md.
|
|
141
141
|
*/
|
|
142
142
|
baseMerge?: BaseMerge;
|
|
143
|
+
/**
|
|
144
|
+
* MERGE A CONCURRENT WRITE BEFORE IT BECOMES A CONFLICT. Called by the fence only when this event would be
|
|
145
|
+
* applied as a conflict — the sub-items it writes (`merge.scope`) were changed by a writer it never saw. The
|
|
146
|
+
* event knows its data: it merges what it can against the base it carries and the state it lands on, and
|
|
147
|
+
* returns the rewritten event. `detail` undefined → everything merged, no conflict marker; `detail` set → what
|
|
148
|
+
* could not be merged, stored on the marker for the resolution UI (the app's own shape); `null` → cannot reason
|
|
149
|
+
* about it (the plain conflict stands: applied and marked). Pure and deterministic — it runs in every fold.
|
|
150
|
+
* See docs/17-editing-and-merging.md §4.
|
|
151
|
+
*/
|
|
152
|
+
resolveConcurrent?: (state: any, eventData: any, ctx: {
|
|
153
|
+
theirsSess: string | null;
|
|
154
|
+
}) => {
|
|
155
|
+
eventData: any;
|
|
156
|
+
detail?: unknown;
|
|
157
|
+
} | null;
|
|
143
158
|
}
|
|
144
159
|
export interface BaseMerge<V = any> {
|
|
145
160
|
/** The value this event writes, as the state holds it right now (theirs). `undefined` → nothing to merge. */
|
package/docs/02-manifest.md
CHANGED
|
@@ -38,14 +38,22 @@ schema is shipped at `schemas/plugin.schema.json`.
|
|
|
38
38
|
| `entry` | the schema module without extension (`app`) |
|
|
39
39
|
| `icon` | a lucide-react name from the curated set (the workbench lists them when yours is wrong); missing → the generic glyph, never a broken tile |
|
|
40
40
|
| `author` | stamped by the workbench at scaffold: `url` is `esoul:user:<id>`, and it is what the release script and the id-collision check read. No email — a manifest may become public |
|
|
41
|
-
| `ops` | server operations exported by `server.ts` (docs/06) |
|
|
41
|
+
| `ops` | server operations exported by `server.ts` (docs/06). A list of names (each `write`), or an object `name → { "access": "read" \| "public" \| "write" \| "roles", "roles"?, "requires"?: "account" }` (docs/13) |
|
|
42
|
+
| `routes` | HTTP routes `/api/plugins/<id>/route/<name>` served by `server.ts`, same object form; `"access": "token"` makes a door for a machine (`mintRouteToken`, docs/06) |
|
|
42
43
|
| `webhooks` | inbound routes `POST /api/plugins/<id>/webhook/<hook>` served by `server.ts` (docs/06) |
|
|
43
|
-
| `kickableTasks` | task names the **browser** may kick (docs/07) |
|
|
44
|
+
| `kickableTasks` | task names the **browser** may kick (docs/07); list or the object form |
|
|
44
45
|
| `pollTasks` | tasks the platform kicks on a cadence, 5-minute granularity, minimum 5 (docs/07) |
|
|
46
|
+
| `roles.vocabulary` / `roles.default` | your app's own words for people (`["staff", "customer"]`) and which word each platform role starts as (docs/13) |
|
|
45
47
|
| `roles.attributes` | what a grant may say about a person, typed: `{ "customer": "int" }` — rules scope a role by `viewer.<attribute>` (docs/13) |
|
|
46
48
|
| `roles.custom` | the envelope an OWNER composes roles inside at runtime — fields a role may be scoped by / denied / change, transitions, surfaces, attributes (docs/13) |
|
|
47
49
|
| `workspaceTools` | the grant wall for calling OTHER apps' tools — `<applicationType>:<tool base name>`; absent = no cross-app access (docs/04, 06) |
|
|
48
|
-
| `
|
|
50
|
+
| `db` | your own tables: fields, indexes, per-row `rules`, `sealed` fields — generated and scoped by the platform, additive on update (docs/14) |
|
|
51
|
+
| `channel` | realtime topics and who hears each (audiences) (docs/15) |
|
|
52
|
+
| `uses` / `provides` | binding slots another app fills by contract, and the contracts you provide (docs/16) |
|
|
53
|
+
| `credentials` | the person's accounts the app uses, one slot for now: `family: "google"` (`scopes`), `"oauth2"` (any OAuth 2.0 provider — `provider: { name, authorizeUrl, tokenUrl, scopes, hosts }`) or `"apiKey"` (`provider: { name, hosts, header?, prefix? }`), plus `why`, `label?`, `background?`. The person connects once in Account settings and assigns the account; your code calls through `credentials(ctx).slot(name).fetch` and never sees the token, key or client secret (docs/08) |
|
|
54
|
+
| `llm` | model calls billed to the owner: tiers, `dailyBudgetUsd`, `why`; called with `llm(ctx)` / `ctx.llm` — never a model SDK (docs/19) |
|
|
55
|
+
| `device` | parts that run on the person's computers: `commands`, `program` (`main`, `resident`, `setup`, `selfTest`), `config`, `redact`, `onAppDeleted` (docs/18, 20) |
|
|
56
|
+
| `connections` | older: OAuth2 / API-key connections whose token your code reads itself (`getPluginConnectionCredentials`); prefer a `credentials` slot. **Names of env vars, never values**, and only the app's own: `PLUGIN_<ID>__<NAME>`; endpoints https (docs/08 §9) |
|
|
49
57
|
| `fileSources` / `fileProviders` | consent to read workspace files and providers; providers you contribute (docs/09) |
|
|
50
58
|
| `fileProducers` | `[{ key, label }]` — your app's own bytes as a file-transfer source; implemented by `pluginServer.fileProducers[key]` (docs/09) |
|
|
51
59
|
| `platformApi` | refuse install outside `[min, max]` of the platform contract version |
|