@dooc-dev/dooc 0.0.0-stage → 0.0.1
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 +26 -2
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +244 -0
- package/dist/code.global.js +285 -0
- package/dist/compiler/bundle-urls.d.ts +3 -0
- package/dist/compiler/bundle-urls.js +294 -0
- package/dist/compiler/bundle.d.ts +2 -0
- package/dist/compiler/bundle.js +100 -0
- package/dist/compiler/check-prose.d.ts +11 -0
- package/dist/compiler/check-prose.js +138 -0
- package/dist/compiler/check-types.d.ts +44 -0
- package/dist/compiler/check-types.js +1 -0
- package/dist/compiler/check.d.ts +5 -0
- package/dist/compiler/check.js +450 -0
- package/dist/compiler/code-blocks.d.ts +17 -0
- package/dist/compiler/code-blocks.js +198 -0
- package/dist/compiler/code-languages.d.ts +10 -0
- package/dist/compiler/code-languages.js +109 -0
- package/dist/compiler/compile.d.ts +95 -0
- package/dist/compiler/compile.js +410 -0
- package/dist/compiler/config.d.ts +7 -0
- package/dist/compiler/config.js +52 -0
- package/dist/compiler/dialect.d.ts +24 -0
- package/dist/compiler/dialect.js +198 -0
- package/dist/compiler/drawio.d.ts +26 -0
- package/dist/compiler/drawio.js +176 -0
- package/dist/compiler/figures.d.ts +14 -0
- package/dist/compiler/figures.js +221 -0
- package/dist/compiler/file-cache.d.ts +14 -0
- package/dist/compiler/file-cache.js +61 -0
- package/dist/compiler/files.d.ts +29 -0
- package/dist/compiler/files.js +431 -0
- package/dist/compiler/fonts.d.ts +10 -0
- package/dist/compiler/fonts.js +54 -0
- package/dist/compiler/format.d.ts +1 -0
- package/dist/compiler/format.js +42 -0
- package/dist/compiler/from-mdast.d.ts +16 -0
- package/dist/compiler/from-mdast.js +142 -0
- package/dist/compiler/icons.d.ts +14 -0
- package/dist/compiler/icons.js +85 -0
- package/dist/compiler/language-codes.d.ts +7 -0
- package/dist/compiler/language-codes.js +17 -0
- package/dist/compiler/locales.d.ts +7 -0
- package/dist/compiler/locales.js +67 -0
- package/dist/compiler/manifest.d.ts +6 -0
- package/dist/compiler/manifest.js +30 -0
- package/dist/compiler/ops.d.ts +44 -0
- package/dist/compiler/ops.js +362 -0
- package/dist/compiler/public-file.d.ts +2 -0
- package/dist/compiler/public-file.js +93 -0
- package/dist/compiler/serialize.d.ts +3 -0
- package/dist/compiler/serialize.js +16 -0
- package/dist/compiler/terms.d.ts +9 -0
- package/dist/compiler/terms.js +44 -0
- package/dist/compiler/textfile-remote.d.ts +12 -0
- package/dist/compiler/textfile-remote.js +124 -0
- package/dist/compiler/textfile.d.ts +9 -0
- package/dist/compiler/textfile.js +113 -0
- package/dist/compiler/theme-variants.d.ts +4 -0
- package/dist/compiler/theme-variants.js +22 -0
- package/dist/compiler/zip.d.ts +6 -0
- package/dist/compiler/zip.js +48 -0
- package/dist/core/blocks.d.ts +99 -0
- package/dist/core/blocks.js +29 -0
- package/dist/core/code-file.d.ts +12 -0
- package/dist/core/code-file.js +68 -0
- package/dist/core/content-metadata.d.ts +48 -0
- package/dist/core/content-metadata.js +264 -0
- package/dist/core/content-types.d.ts +45 -0
- package/dist/core/content-types.js +1 -0
- package/dist/core/figure-frame.d.ts +12 -0
- package/dist/core/figure-frame.js +108 -0
- package/dist/core/file-embed.d.ts +13 -0
- package/dist/core/file-embed.js +43 -0
- package/dist/core/files.d.ts +15 -0
- package/dist/core/files.js +59 -0
- package/dist/core/flow-base-css.generated.d.ts +1 -0
- package/dist/core/flow-base-css.generated.js +4 -0
- package/dist/core/flow-css.d.ts +9 -0
- package/dist/core/flow-css.js +54 -0
- package/dist/core/graph-common.d.ts +32 -0
- package/dist/core/graph-common.js +113 -0
- package/dist/core/graph.d.ts +117 -0
- package/dist/core/graph.js +480 -0
- package/dist/core/html.d.ts +29 -0
- package/dist/core/html.js +444 -0
- package/dist/core/icons.d.ts +1 -0
- package/dist/core/icons.js +1 -0
- package/dist/core/inline.d.ts +27 -0
- package/dist/core/inline.js +42 -0
- package/dist/core/language.d.ts +41 -0
- package/dist/core/language.js +44 -0
- package/dist/core/languages/ja.d.ts +2 -0
- package/dist/core/languages/ja.js +5 -0
- package/dist/core/markdown.d.ts +27 -0
- package/dist/core/markdown.js +154 -0
- package/dist/core/previewers.d.ts +37 -0
- package/dist/core/previewers.js +103 -0
- package/dist/core/routing.d.ts +20 -0
- package/dist/core/routing.js +64 -0
- package/dist/core/schema-comments.d.ts +14 -0
- package/dist/core/schema-comments.js +52 -0
- package/dist/core/schema-graph-flow.d.ts +9 -0
- package/dist/core/schema-graph-flow.js +379 -0
- package/dist/core/schema-graph-mount.d.ts +9 -0
- package/dist/core/schema-graph-mount.js +105 -0
- package/dist/core/schema-graph.d.ts +254 -0
- package/dist/core/schema-graph.js +981 -0
- package/dist/core/table-ui.d.ts +2 -0
- package/dist/core/table-ui.js +180 -0
- package/dist/core/taxonomy.d.ts +12 -0
- package/dist/core/taxonomy.js +49 -0
- package/dist/core/term-reference.d.ts +5 -0
- package/dist/core/term-reference.js +19 -0
- package/dist/core/terms-ui.d.ts +6 -0
- package/dist/core/terms-ui.js +22 -0
- package/dist/core/textfile.d.ts +10 -0
- package/dist/core/textfile.js +39 -0
- package/dist/core/theme-variants.d.ts +3 -0
- package/dist/core/theme-variants.js +37 -0
- package/dist/core/toc-ui.d.ts +2 -0
- package/dist/core/toc-ui.js +161 -0
- package/dist/core/video-ui.d.ts +2 -0
- package/dist/core/video-ui.js +253 -0
- package/dist/core/workspace.d.ts +15 -0
- package/dist/core/workspace.js +28 -0
- package/dist/edit.global.js +305 -0
- package/dist/flow.global.js +4959 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.js +31 -0
- package/dist/mcp.d.ts +24 -0
- package/dist/mcp.js +467 -0
- package/dist/plugins/actions.d.ts +45 -0
- package/dist/plugins/actions.js +125 -0
- package/dist/plugins/blocks.d.ts +92 -0
- package/dist/plugins/blocks.js +99 -0
- package/dist/plugins/index.d.ts +4 -0
- package/dist/plugins/index.js +1 -0
- package/dist/plugins/oauth.d.ts +14 -0
- package/dist/plugins/oauth.js +164 -0
- package/dist/plugins/runtime.d.ts +24 -0
- package/dist/plugins/runtime.js +339 -0
- package/dist/plugins/types.d.ts +56 -0
- package/dist/plugins/types.js +1 -0
- package/dist/plugins/view.d.ts +29 -0
- package/dist/plugins/view.js +60 -0
- package/dist/ui.global.js +77 -0
- package/dist/viewer/assets.d.ts +2 -0
- package/dist/viewer/assets.js +3 -0
- package/dist/viewer/build.d.ts +12 -0
- package/dist/viewer/build.js +55 -0
- package/dist/viewer/bundle-network.d.ts +2 -0
- package/dist/viewer/bundle-network.js +88 -0
- package/dist/viewer/code-file.d.ts +20 -0
- package/dist/viewer/code-file.js +42 -0
- package/dist/viewer/editor-tools.d.ts +38 -0
- package/dist/viewer/editor-tools.js +211 -0
- package/dist/viewer/file-references.d.ts +14 -0
- package/dist/viewer/file-references.js +38 -0
- package/dist/viewer/font-picker.d.ts +4 -0
- package/dist/viewer/font-picker.js +145 -0
- package/dist/viewer/font-settings.d.ts +11 -0
- package/dist/viewer/font-settings.js +50 -0
- package/dist/viewer/fonts/LICENSE.txt +119 -0
- package/dist/viewer/fonts/udev-gothic-regular.woff2 +0 -0
- package/dist/viewer/fonts.d.ts +2 -0
- package/dist/viewer/fonts.js +13 -0
- package/dist/viewer/git-diff.d.ts +3 -0
- package/dist/viewer/git-diff.js +33 -0
- package/dist/viewer/git-types.d.ts +73 -0
- package/dist/viewer/git-types.js +1 -0
- package/dist/viewer/git.d.ts +5 -0
- package/dist/viewer/git.js +337 -0
- package/dist/viewer/hmr.d.ts +53 -0
- package/dist/viewer/hmr.js +335 -0
- package/dist/viewer/i18n.d.ts +6 -0
- package/dist/viewer/i18n.js +65 -0
- package/dist/viewer/ja-lint.d.ts +3 -0
- package/dist/viewer/ja-lint.js +44 -0
- package/dist/viewer/language-picker.d.ts +2 -0
- package/dist/viewer/language-picker.js +233 -0
- package/dist/viewer/languages.d.ts +11 -0
- package/dist/viewer/languages.js +20 -0
- package/dist/viewer/locales/eng.json +294 -0
- package/dist/viewer/locales/jpn.json +294 -0
- package/dist/viewer/locales/zho.json +294 -0
- package/dist/viewer/nav-focus.d.ts +2 -0
- package/dist/viewer/nav-focus.js +64 -0
- package/dist/viewer/plugin-blocks.d.ts +3 -0
- package/dist/viewer/plugin-blocks.js +115 -0
- package/dist/viewer/plugin-config.d.ts +35 -0
- package/dist/viewer/plugin-config.js +57 -0
- package/dist/viewer/plugin-view.d.ts +3 -0
- package/dist/viewer/plugin-view.js +28 -0
- package/dist/viewer/plugins.d.ts +8 -0
- package/dist/viewer/plugins.js +214 -0
- package/dist/viewer/resize.d.ts +4 -0
- package/dist/viewer/resize.js +238 -0
- package/dist/viewer/resource-library.d.ts +29 -0
- package/dist/viewer/resource-library.js +104 -0
- package/dist/viewer/search.d.ts +16 -0
- package/dist/viewer/search.js +358 -0
- package/dist/viewer/serve.d.ts +206 -0
- package/dist/viewer/serve.js +3233 -0
- package/dist/viewer/site-icon.d.ts +8 -0
- package/dist/viewer/site-icon.js +56 -0
- package/dist/viewer/textfile.d.ts +9 -0
- package/dist/viewer/textfile.js +50 -0
- package/dist/viewer/theme-picker.d.ts +2 -0
- package/dist/viewer/theme-picker.js +135 -0
- package/dist/viewer/workspace.d.ts +4 -0
- package/dist/viewer/workspace.js +47 -0
- package/package.json +127 -5
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
2
|
+
import { validatePluginActionRequest, validatePluginActions, validatePluginActionValues, } from "./actions.js";
|
|
3
|
+
import { fetchPluginJson, httpsUrl, isPluginError, PluginError, parseOAuthToken, validatePlugins, } from "./oauth.js";
|
|
4
|
+
import { parsePluginUrl, validatePluginView } from "./view.js";
|
|
5
|
+
const nonce = () => randomBytes(32).toString("base64url");
|
|
6
|
+
const expired = () => new PluginError(401, "Connect this plugin again");
|
|
7
|
+
const expiry = (token) => token.expires_in === undefined ? Infinity : Date.now() + token.expires_in * 1000;
|
|
8
|
+
export const createPluginRuntime = (definitions, transport) => {
|
|
9
|
+
const plugins = new Map(validatePlugins(definitions).map((plugin) => [plugin.id, plugin]));
|
|
10
|
+
// shortcut: sessions and tokens live in memory for eight hours; add an OS credential store for persistent login.
|
|
11
|
+
const sessions = new Map();
|
|
12
|
+
const sessionFor = (id) => {
|
|
13
|
+
const session = sessions.get(id);
|
|
14
|
+
if (!session || session.expires <= Date.now()) {
|
|
15
|
+
sessions.delete(id);
|
|
16
|
+
throw expired();
|
|
17
|
+
}
|
|
18
|
+
return session;
|
|
19
|
+
};
|
|
20
|
+
const pluginFor = (id) => {
|
|
21
|
+
const plugin = plugins.get(id);
|
|
22
|
+
if (!plugin)
|
|
23
|
+
throw new PluginError(404, "Plugin is not configured");
|
|
24
|
+
return plugin;
|
|
25
|
+
};
|
|
26
|
+
const exchange = async (plugin, fields) => {
|
|
27
|
+
const { oauth } = plugin;
|
|
28
|
+
const body = new URLSearchParams({ ...fields, client_id: oauth.clientId });
|
|
29
|
+
if (oauth.clientSecret)
|
|
30
|
+
body.set("client_secret", oauth.clientSecret);
|
|
31
|
+
const result = await fetchPluginJson(oauth.tokenUrl, {
|
|
32
|
+
method: "POST",
|
|
33
|
+
body,
|
|
34
|
+
headers: { accept: "application/json" },
|
|
35
|
+
}, true, transport);
|
|
36
|
+
if (!result ||
|
|
37
|
+
typeof result !== "object" ||
|
|
38
|
+
"error" in result ||
|
|
39
|
+
("ok" in result && result.ok === false)) {
|
|
40
|
+
if (result &&
|
|
41
|
+
typeof result === "object" &&
|
|
42
|
+
"error" in result &&
|
|
43
|
+
result.error === "invalid_grant")
|
|
44
|
+
throw expired();
|
|
45
|
+
throw new PluginError(502, "OAuth exchange failed");
|
|
46
|
+
}
|
|
47
|
+
try {
|
|
48
|
+
return parseOAuthToken(oauth.parseToken ? oauth.parseToken(result) : result);
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
throw new PluginError(502, "Invalid OAuth token response");
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
const context = (plugin, accessToken, write = false, check = () => { }) => ({
|
|
55
|
+
request: async (source, init) => {
|
|
56
|
+
check();
|
|
57
|
+
if (plugins.get(plugin.id) !== plugin)
|
|
58
|
+
throw expired();
|
|
59
|
+
const url = httpsUrl(source);
|
|
60
|
+
if (!plugin.apiOrigins.includes(url.origin))
|
|
61
|
+
throw new PluginError(400, "Plugin API origin is not allowed");
|
|
62
|
+
if (init?.method !== undefined &&
|
|
63
|
+
!(write ? ["GET", "POST", "PUT", "PATCH", "DELETE"] : ["GET", "POST"]).includes(init.method))
|
|
64
|
+
throw new PluginError(400, "Invalid plugin request method");
|
|
65
|
+
return fetchPluginJson(url.href, {
|
|
66
|
+
method: init?.method ?? "GET",
|
|
67
|
+
headers: {
|
|
68
|
+
authorization: `Bearer ${accessToken}`,
|
|
69
|
+
accept: "application/json",
|
|
70
|
+
...(init?.body === undefined ? {} : { "content-type": "application/json" }),
|
|
71
|
+
},
|
|
72
|
+
...(init?.body === undefined ? {} : { body: JSON.stringify(init.body) }),
|
|
73
|
+
}, false, transport);
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
const assertCurrent = (id, pluginId, connection) => {
|
|
77
|
+
if (sessionFor(id).connections.get(pluginId) !== connection)
|
|
78
|
+
throw expired();
|
|
79
|
+
};
|
|
80
|
+
const unregister = (pluginId) => {
|
|
81
|
+
plugins.delete(pluginId);
|
|
82
|
+
for (const session of sessions.values()) {
|
|
83
|
+
session.connections.delete(pluginId);
|
|
84
|
+
session.attempts.delete(pluginId);
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
const access = async (id, pluginId, source, run) => {
|
|
88
|
+
let url;
|
|
89
|
+
try {
|
|
90
|
+
url = parsePluginUrl(source);
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
throw new PluginError(400, "Invalid service URL");
|
|
94
|
+
}
|
|
95
|
+
const session = sessionFor(id);
|
|
96
|
+
const plugin = pluginFor(pluginId);
|
|
97
|
+
if (!plugin.matches(url))
|
|
98
|
+
throw new PluginError(400, "URL is not supported by this plugin");
|
|
99
|
+
const connection = session.connections.get(pluginId);
|
|
100
|
+
if (!connection)
|
|
101
|
+
throw expired();
|
|
102
|
+
try {
|
|
103
|
+
if (connection.expires <= Date.now() + 30_000) {
|
|
104
|
+
const refreshToken = connection.token.refresh_token;
|
|
105
|
+
if (!refreshToken)
|
|
106
|
+
throw expired();
|
|
107
|
+
connection.refreshing ??= (async () => {
|
|
108
|
+
try {
|
|
109
|
+
const token = await exchange(plugin, {
|
|
110
|
+
grant_type: "refresh_token",
|
|
111
|
+
refresh_token: refreshToken,
|
|
112
|
+
});
|
|
113
|
+
assertCurrent(id, pluginId, connection);
|
|
114
|
+
connection.token = {
|
|
115
|
+
...token,
|
|
116
|
+
refresh_token: token.refresh_token ?? refreshToken,
|
|
117
|
+
scope: token.scope ?? connection.token.scope,
|
|
118
|
+
};
|
|
119
|
+
connection.expires = expiry(token);
|
|
120
|
+
}
|
|
121
|
+
finally {
|
|
122
|
+
connection.refreshing = undefined;
|
|
123
|
+
}
|
|
124
|
+
})();
|
|
125
|
+
await connection.refreshing;
|
|
126
|
+
}
|
|
127
|
+
assertCurrent(id, pluginId, connection);
|
|
128
|
+
const result = await run(plugin, url, connection);
|
|
129
|
+
assertCurrent(id, pluginId, connection);
|
|
130
|
+
return result;
|
|
131
|
+
}
|
|
132
|
+
catch (error) {
|
|
133
|
+
if (isPluginError(error) &&
|
|
134
|
+
error.status === 401 &&
|
|
135
|
+
session.connections.get(pluginId) === connection)
|
|
136
|
+
session.connections.delete(pluginId);
|
|
137
|
+
throw error;
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
return {
|
|
141
|
+
resolve(source) {
|
|
142
|
+
let url;
|
|
143
|
+
try {
|
|
144
|
+
url = parsePluginUrl(source);
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
throw new PluginError(400, "Invalid service URL");
|
|
148
|
+
}
|
|
149
|
+
return [...plugins.values()].find((plugin) => plugin.matches(url))?.id;
|
|
150
|
+
},
|
|
151
|
+
register(plugin) {
|
|
152
|
+
validatePlugins([plugin]);
|
|
153
|
+
unregister(plugin.id);
|
|
154
|
+
plugins.set(plugin.id, { ...plugin });
|
|
155
|
+
},
|
|
156
|
+
unregister,
|
|
157
|
+
createSession() {
|
|
158
|
+
for (const [id, session] of sessions)
|
|
159
|
+
if (session.expires <= Date.now())
|
|
160
|
+
sessions.delete(id);
|
|
161
|
+
if (sessions.size >= 128)
|
|
162
|
+
throw new PluginError(503, "Too many plugin sessions");
|
|
163
|
+
const id = nonce();
|
|
164
|
+
sessions.set(id, {
|
|
165
|
+
expires: Date.now() + 8 * 60 * 60 * 1000,
|
|
166
|
+
connections: new Map(),
|
|
167
|
+
attempts: new Map(),
|
|
168
|
+
});
|
|
169
|
+
return id;
|
|
170
|
+
},
|
|
171
|
+
hasSession(id) {
|
|
172
|
+
try {
|
|
173
|
+
sessionFor(id);
|
|
174
|
+
return true;
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
return false;
|
|
178
|
+
}
|
|
179
|
+
},
|
|
180
|
+
connect(id, pluginId, redirectUri) {
|
|
181
|
+
const session = sessionFor(id);
|
|
182
|
+
const plugin = pluginFor(pluginId);
|
|
183
|
+
const redirect = new URL(redirectUri);
|
|
184
|
+
if (redirect.protocol !== "http:" ||
|
|
185
|
+
!["localhost", "127.0.0.1", "[::1]"].includes(redirect.hostname) ||
|
|
186
|
+
redirect.username ||
|
|
187
|
+
redirect.password ||
|
|
188
|
+
redirect.search ||
|
|
189
|
+
redirect.hash ||
|
|
190
|
+
redirect.pathname !== `/api/plugins/callback/${pluginId}`)
|
|
191
|
+
throw new PluginError(400, "OAuth callback must use the local viewer");
|
|
192
|
+
const attempt = {
|
|
193
|
+
state: nonce(),
|
|
194
|
+
verifier: nonce(),
|
|
195
|
+
redirectUri,
|
|
196
|
+
expires: Date.now() + 10 * 60 * 1000,
|
|
197
|
+
};
|
|
198
|
+
session.connections.delete(pluginId);
|
|
199
|
+
session.attempts.set(pluginId, attempt);
|
|
200
|
+
const url = new URL(plugin.oauth.authorizationUrl);
|
|
201
|
+
for (const [key, value] of Object.entries(plugin.oauth.authorizationParams ?? {}))
|
|
202
|
+
url.searchParams.set(key, value);
|
|
203
|
+
url.searchParams.set(plugin.oauth.scopeParameter ?? "scope", plugin.oauth.scopes.join(plugin.oauth.scopeSeparator ?? " "));
|
|
204
|
+
for (const [key, value] of Object.entries({
|
|
205
|
+
client_id: plugin.oauth.clientId,
|
|
206
|
+
redirect_uri: redirectUri,
|
|
207
|
+
response_type: "code",
|
|
208
|
+
state: attempt.state,
|
|
209
|
+
code_challenge: createHash("sha256").update(attempt.verifier).digest("base64url"),
|
|
210
|
+
code_challenge_method: "S256",
|
|
211
|
+
}))
|
|
212
|
+
url.searchParams.set(key, value);
|
|
213
|
+
return url.href;
|
|
214
|
+
},
|
|
215
|
+
async callback(id, pluginId, redirectUri, params) {
|
|
216
|
+
const session = sessionFor(id);
|
|
217
|
+
const plugin = pluginFor(pluginId);
|
|
218
|
+
const attempt = session.attempts.get(pluginId);
|
|
219
|
+
if (!attempt?.state ||
|
|
220
|
+
attempt.expires <= Date.now() ||
|
|
221
|
+
attempt.redirectUri !== redirectUri ||
|
|
222
|
+
params.getAll("state").length !== 1 ||
|
|
223
|
+
params.get("state") !== attempt.state)
|
|
224
|
+
throw new PluginError(400, "Invalid or expired OAuth state");
|
|
225
|
+
// Consume the state before exchanging a code so callbacks cannot be replayed.
|
|
226
|
+
attempt.state = "";
|
|
227
|
+
const code = params.get("code");
|
|
228
|
+
if (params.has("error") || !code || params.getAll("code").length !== 1)
|
|
229
|
+
throw new PluginError(400, "OAuth authorization was not completed");
|
|
230
|
+
const token = await exchange(plugin, {
|
|
231
|
+
grant_type: "authorization_code",
|
|
232
|
+
code,
|
|
233
|
+
redirect_uri: redirectUri,
|
|
234
|
+
code_verifier: attempt.verifier,
|
|
235
|
+
});
|
|
236
|
+
const expires = expiry(token);
|
|
237
|
+
const user = await plugin.identifyUser(context(plugin, token.access_token));
|
|
238
|
+
if (!user ||
|
|
239
|
+
typeof user.id !== "string" ||
|
|
240
|
+
!user.id ||
|
|
241
|
+
typeof user.name !== "string" ||
|
|
242
|
+
!user.name ||
|
|
243
|
+
user.id.length > 512 ||
|
|
244
|
+
user.name.length > 512)
|
|
245
|
+
throw new PluginError(502, "Plugin could not identify the authorized user");
|
|
246
|
+
if (sessionFor(id).attempts.get(pluginId) !== attempt)
|
|
247
|
+
throw expired();
|
|
248
|
+
session.attempts.delete(pluginId);
|
|
249
|
+
session.connections.set(pluginId, {
|
|
250
|
+
token,
|
|
251
|
+
expires,
|
|
252
|
+
user: { id: user.id, name: user.name },
|
|
253
|
+
});
|
|
254
|
+
},
|
|
255
|
+
disconnect(id, pluginId) {
|
|
256
|
+
const session = sessionFor(id);
|
|
257
|
+
session.connections.delete(pluginId);
|
|
258
|
+
session.attempts.delete(pluginId);
|
|
259
|
+
},
|
|
260
|
+
read(id, pluginId, source) {
|
|
261
|
+
return access(id, pluginId, source, async (plugin, url, connection) => ({
|
|
262
|
+
user: connection.user,
|
|
263
|
+
view: validatePluginView(await plugin.read(url, {
|
|
264
|
+
...context(plugin, connection.token.access_token, false, () => assertCurrent(id, pluginId, connection)),
|
|
265
|
+
user: { ...connection.user },
|
|
266
|
+
})),
|
|
267
|
+
}));
|
|
268
|
+
},
|
|
269
|
+
actions(id, pluginId, source) {
|
|
270
|
+
return access(id, pluginId, source, async (plugin, url, connection) => ({
|
|
271
|
+
user: connection.user,
|
|
272
|
+
actions: validatePluginActions(plugin.actions && plugin.act
|
|
273
|
+
? await plugin.actions(url, {
|
|
274
|
+
...context(plugin, connection.token.access_token, false, () => assertCurrent(id, pluginId, connection)),
|
|
275
|
+
user: { ...connection.user },
|
|
276
|
+
})
|
|
277
|
+
: []),
|
|
278
|
+
}));
|
|
279
|
+
},
|
|
280
|
+
act(id, pluginId, source, input, requestId) {
|
|
281
|
+
const operation = validatePluginActionRequest(input);
|
|
282
|
+
if (typeof requestId !== "string" || !/^[a-zA-Z0-9-]{16,80}$/.test(requestId))
|
|
283
|
+
throw new PluginError(400, "Invalid operation ID");
|
|
284
|
+
return access(id, pluginId, source, async (plugin, url, connection) => {
|
|
285
|
+
const { actions: listActions, act } = plugin;
|
|
286
|
+
if (!listActions || !act)
|
|
287
|
+
throw new PluginError(403, "Plugin operations are not supported");
|
|
288
|
+
const fingerprint = createHash("sha256")
|
|
289
|
+
.update(JSON.stringify([url.href, operation]))
|
|
290
|
+
.digest("hex");
|
|
291
|
+
connection.operations ??= new Map();
|
|
292
|
+
const requests = connection.operations;
|
|
293
|
+
let request = requests.get(requestId);
|
|
294
|
+
if (request && request.fingerprint !== fingerprint)
|
|
295
|
+
throw new PluginError(409, "Operation ID was already used for another request");
|
|
296
|
+
if (!request) {
|
|
297
|
+
// shortcut: retain 128 operation results per connection; use durable idempotency for longer sessions.
|
|
298
|
+
if (requests.size >= 128)
|
|
299
|
+
throw new PluginError(429, "Reconnect before performing more operations");
|
|
300
|
+
const previous = connection.writing;
|
|
301
|
+
const promise = (async () => {
|
|
302
|
+
try {
|
|
303
|
+
await previous;
|
|
304
|
+
}
|
|
305
|
+
catch {
|
|
306
|
+
// A failed operation must not block later writes.
|
|
307
|
+
}
|
|
308
|
+
const check = () => assertCurrent(id, pluginId, connection);
|
|
309
|
+
check();
|
|
310
|
+
const user = { ...connection.user };
|
|
311
|
+
const actions = validatePluginActions(await listActions.call(plugin, url, {
|
|
312
|
+
...context(plugin, connection.token.access_token, false, check),
|
|
313
|
+
user,
|
|
314
|
+
}));
|
|
315
|
+
const action = actions.find((a) => a.id === operation.id);
|
|
316
|
+
if (!action)
|
|
317
|
+
throw new PluginError(403, "Operation is not available for this user and URL");
|
|
318
|
+
validatePluginActionValues(action, operation);
|
|
319
|
+
check();
|
|
320
|
+
const result = await act.call(plugin, url, {
|
|
321
|
+
...context(plugin, connection.token.access_token, true, check),
|
|
322
|
+
user,
|
|
323
|
+
}, operation);
|
|
324
|
+
if (!result ||
|
|
325
|
+
typeof result.message !== "string" ||
|
|
326
|
+
result.message.length > 4000 ||
|
|
327
|
+
(result.deleted !== undefined && typeof result.deleted !== "boolean"))
|
|
328
|
+
throw new PluginError(502, "Invalid plugin operation result");
|
|
329
|
+
return result;
|
|
330
|
+
})();
|
|
331
|
+
connection.writing = promise;
|
|
332
|
+
request = { fingerprint, promise };
|
|
333
|
+
requests.set(requestId, request);
|
|
334
|
+
}
|
|
335
|
+
return { user: connection.user, result: await request.promise };
|
|
336
|
+
});
|
|
337
|
+
},
|
|
338
|
+
};
|
|
339
|
+
};
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/** OAuth credentials belong in environment variables, never in document source. */
|
|
2
|
+
export interface PluginOAuth {
|
|
3
|
+
authorizationUrl: string;
|
|
4
|
+
tokenUrl: string;
|
|
5
|
+
clientId: string;
|
|
6
|
+
clientSecret?: string;
|
|
7
|
+
scopes: readonly string[];
|
|
8
|
+
/** Provider-specific user authorization parameters, such as actor=user. */
|
|
9
|
+
authorizationParams?: Readonly<Record<string, string>>;
|
|
10
|
+
scopeParameter?: string;
|
|
11
|
+
scopeSeparator?: string;
|
|
12
|
+
/** Normalize provider responses; select the user token and reject bot/app tokens. */
|
|
13
|
+
parseToken?: (response: unknown) => OAuthToken;
|
|
14
|
+
}
|
|
15
|
+
export interface OAuthToken {
|
|
16
|
+
access_token: string;
|
|
17
|
+
refresh_token?: string;
|
|
18
|
+
expires_in?: number;
|
|
19
|
+
scope?: string | string[];
|
|
20
|
+
}
|
|
21
|
+
export interface PluginUser {
|
|
22
|
+
/** Include the tenant/workspace in the ID if user IDs are not globally unique. */
|
|
23
|
+
id: string;
|
|
24
|
+
name: string;
|
|
25
|
+
}
|
|
26
|
+
export interface PluginContext {
|
|
27
|
+
/** Authenticated JSON requests to apiOrigins only; credentials never reach the browser. */
|
|
28
|
+
request: (url: string, init?: {
|
|
29
|
+
method?: "GET" | "POST";
|
|
30
|
+
body?: unknown;
|
|
31
|
+
}) => Promise<unknown>;
|
|
32
|
+
}
|
|
33
|
+
export type { PluginContent, PluginView } from "./view.js";
|
|
34
|
+
import type { PluginAction, PluginActionContext, PluginActionRequest, PluginActionResult } from "./actions.js";
|
|
35
|
+
import type { PluginView } from "./view.js";
|
|
36
|
+
/** Trusted server code injected into the viewer, independently of document configuration. */
|
|
37
|
+
export interface DataPlugin {
|
|
38
|
+
id: string;
|
|
39
|
+
/** Match a service URL without fetching it; unmatched embeds remain ordinary links. */
|
|
40
|
+
matches: (url: URL) => boolean;
|
|
41
|
+
oauth: PluginOAuth;
|
|
42
|
+
/** Exact HTTPS origins; API requests and their redirects cannot leave these origins. */
|
|
43
|
+
apiOrigins: readonly string[];
|
|
44
|
+
/** Resolve the token's actual user; reject application identities and provider errors. */
|
|
45
|
+
identifyUser: (context: PluginContext) => Promise<PluginUser>;
|
|
46
|
+
/** Fetch the matched URL's data using read operations and reject provider-level API errors. */
|
|
47
|
+
read: (url: URL, context: PluginContext & {
|
|
48
|
+
user: PluginUser;
|
|
49
|
+
}) => Promise<PluginView>;
|
|
50
|
+
/** Resolve operations available to the current user without performing writes. */
|
|
51
|
+
actions?: (url: URL, context: PluginContext & {
|
|
52
|
+
user: PluginUser;
|
|
53
|
+
}) => Promise<PluginAction[]>;
|
|
54
|
+
/** Recheck provider permissions and resource versions before changing remote data. */
|
|
55
|
+
act?: (url: URL, context: PluginActionContext, request: PluginActionRequest) => Promise<PluginActionResult>;
|
|
56
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { type PluginBlockContent } from "./blocks.js";
|
|
2
|
+
export type PluginContent = PluginBlockContent | {
|
|
3
|
+
format: "html";
|
|
4
|
+
html: string;
|
|
5
|
+
height?: number | "auto";
|
|
6
|
+
} | {
|
|
7
|
+
format: "json";
|
|
8
|
+
preset: "card";
|
|
9
|
+
title: string;
|
|
10
|
+
text: string;
|
|
11
|
+
} | {
|
|
12
|
+
format: "json";
|
|
13
|
+
preset: "list";
|
|
14
|
+
items: string[];
|
|
15
|
+
} | {
|
|
16
|
+
format: "json";
|
|
17
|
+
preset: "table";
|
|
18
|
+
headers: string[];
|
|
19
|
+
rows: (string | number | boolean | null)[][];
|
|
20
|
+
} | {
|
|
21
|
+
format: "json";
|
|
22
|
+
preset: "code";
|
|
23
|
+
value: unknown;
|
|
24
|
+
};
|
|
25
|
+
export type PluginView = PluginContent & {
|
|
26
|
+
collapsed?: PluginContent;
|
|
27
|
+
};
|
|
28
|
+
export declare const validatePluginView: (input: unknown) => PluginView;
|
|
29
|
+
export declare const parsePluginUrl: (input: unknown) => URL;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { validPluginBlocks } from "./blocks.js";
|
|
2
|
+
const validContent = (view) => {
|
|
3
|
+
let valid = false;
|
|
4
|
+
if (view && typeof view === "object") {
|
|
5
|
+
if (view.format === "blocks")
|
|
6
|
+
return validPluginBlocks(view);
|
|
7
|
+
if (view.format === "html")
|
|
8
|
+
valid =
|
|
9
|
+
typeof view.html === "string" &&
|
|
10
|
+
(view.height === undefined ||
|
|
11
|
+
view.height === "auto" ||
|
|
12
|
+
(Number.isInteger(view.height) && view.height >= 32 && view.height <= 1200));
|
|
13
|
+
if (view.format === "json") {
|
|
14
|
+
switch (view.preset) {
|
|
15
|
+
case "card":
|
|
16
|
+
valid = typeof view.title === "string" && typeof view.text === "string";
|
|
17
|
+
break;
|
|
18
|
+
case "list":
|
|
19
|
+
valid =
|
|
20
|
+
Array.isArray(view.items) &&
|
|
21
|
+
view.items.every((item) => typeof item === "string");
|
|
22
|
+
break;
|
|
23
|
+
case "table":
|
|
24
|
+
valid =
|
|
25
|
+
Array.isArray(view.headers) &&
|
|
26
|
+
view.headers.every((h) => typeof h === "string") &&
|
|
27
|
+
Array.isArray(view.rows) &&
|
|
28
|
+
view.rows.every((row) => Array.isArray(row) &&
|
|
29
|
+
row.length === view.headers.length &&
|
|
30
|
+
row.every((cell) => cell === null ||
|
|
31
|
+
["string", "boolean"].includes(typeof cell) ||
|
|
32
|
+
(typeof cell === "number" && Number.isFinite(cell))));
|
|
33
|
+
break;
|
|
34
|
+
case "code":
|
|
35
|
+
valid = view.value !== undefined;
|
|
36
|
+
break;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return valid;
|
|
41
|
+
};
|
|
42
|
+
export const validatePluginView = (input) => {
|
|
43
|
+
const encoded = JSON.stringify(input);
|
|
44
|
+
if (typeof encoded !== "string")
|
|
45
|
+
throw new Error("Invalid plugin view");
|
|
46
|
+
if (new TextEncoder().encode(encoded).length > 2 * 1024 * 1024)
|
|
47
|
+
throw new Error("Plugin view is too large");
|
|
48
|
+
const view = JSON.parse(encoded);
|
|
49
|
+
if (!validContent(view) || (view.collapsed !== undefined && !validContent(view.collapsed)))
|
|
50
|
+
throw new Error("Invalid plugin view");
|
|
51
|
+
return view;
|
|
52
|
+
};
|
|
53
|
+
export const parsePluginUrl = (input) => {
|
|
54
|
+
if (typeof input !== "string" || input.length > 4096 || /[\s\\]/.test(input))
|
|
55
|
+
throw new Error("Expected a service URL");
|
|
56
|
+
const url = new URL(input);
|
|
57
|
+
if (!/^https?:$/.test(url.protocol) || url.username || url.password)
|
|
58
|
+
throw new Error("Expected an HTTP(S) URL without credentials");
|
|
59
|
+
return url;
|
|
60
|
+
};
|