@dooc-dev/dooc 0.0.1 → 0.0.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.
@@ -75,18 +75,19 @@ export type PluginBlock = {
75
75
  } | {
76
76
  type: "divider";
77
77
  };
78
+ export interface PluginIcon {
79
+ light: string;
80
+ dark?: string;
81
+ }
78
82
  export interface PluginBlockContent {
79
- format: "blocks";
80
83
  header?: {
81
84
  title: string;
82
85
  context?: string;
83
86
  reference?: string;
84
- icon?: {
85
- light: string;
86
- dark?: string;
87
- };
87
+ icon?: PluginIcon;
88
88
  };
89
89
  blocks: PluginBlock[];
90
90
  }
91
91
  export declare const pluginLink: (value: unknown) => value is string;
92
+ export declare const validPluginIcon: (value: unknown) => value is PluginIcon;
92
93
  export declare const validPluginBlocks: (value: unknown) => value is PluginBlockContent;
@@ -17,6 +17,7 @@ export const pluginLink = (value) => {
17
17
  const icon = (value) => typeof value === "string" &&
18
18
  value.length <= 100_000 &&
19
19
  /^data:image\/(?:svg\+xml|png|webp);base64,[A-Za-z0-9+/]+={0,2}$/.test(value);
20
+ export const validPluginIcon = (value) => record(value) && icon(value.light) && optional(value.dark, icon);
20
21
  const edit = (value) => record(value) && text(value.action) && optional(value.field, text);
21
22
  const inlines = (value) => array(value, (item) => text(item) ||
22
23
  (record(item) &&
@@ -89,11 +90,11 @@ export const validPluginBlocks = (value) => {
89
90
  }
90
91
  });
91
92
  return (record(value) &&
92
- value.format === "blocks" &&
93
+ !("format" in value) &&
93
94
  blocks(value.blocks, 0) &&
94
95
  optional(value.header, (h) => record(h) &&
95
96
  text(h.title) &&
96
97
  optional(h.context, text) &&
97
98
  optional(h.reference, text) &&
98
- optional(h.icon, (v) => record(v) && icon(v.light) && optional(v.dark, icon))));
99
+ optional(h.icon, validPluginIcon)));
99
100
  };
@@ -1,4 +1,4 @@
1
1
  export type { PluginAction, PluginActionContext, PluginActionField, PluginActionRequest, PluginActionResult, } from "./actions.js";
2
- export type { PluginBlock, PluginBlockContent, PluginCell, PluginEdit, PluginInline, PluginTone, } from "./blocks.js";
2
+ export type { PluginBlock, PluginBlockContent, PluginCell, PluginEdit, PluginIcon, PluginInline, PluginTone, } from "./blocks.js";
3
3
  export { PluginError, parseOAuthToken } from "./oauth.js";
4
4
  export type { DataPlugin, OAuthToken, PluginContent, PluginContext, PluginOAuth, PluginUser, PluginView, } from "./types.js";
@@ -0,0 +1,18 @@
1
+ import type { DataPlugin, OAuthToken, PluginUser } from "./types.js";
2
+ export interface SavedConnection {
3
+ token: OAuthToken;
4
+ expires: number;
5
+ user: PluginUser;
6
+ }
7
+ export interface PluginCredentialStore {
8
+ get(plugin: DataPlugin): SavedConnection | undefined;
9
+ set(plugin: DataPlugin, value?: SavedConnection): void;
10
+ withLock?<T>(plugin: DataPlugin, run: () => Promise<T>): Promise<T>;
11
+ }
12
+ /** Personal state is outside document roots and never included in exported bundles. */
13
+ export declare const createLocalPluginStorage: (userDir?: string) => {
14
+ read: (name: string) => unknown;
15
+ write: (name: string, value: unknown) => void;
16
+ credentials: PluginCredentialStore;
17
+ };
18
+ export type LocalPluginStorage = ReturnType<typeof createLocalPluginStorage>;
@@ -0,0 +1,115 @@
1
+ import { createHash, randomBytes } from "node:crypto";
2
+ import { lstatSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { setTimeout as delay } from "node:timers/promises";
6
+ import { parseOAuthToken } from "./oauth.js";
7
+ /** Personal state is outside document roots and never included in exported bundles. */
8
+ export const createLocalPluginStorage = (userDir = process.env.DOOC_USER_DIR ?? join(homedir(), ".dooc")) => {
9
+ const folder = join(userDir, "plugins");
10
+ const path = (name) => {
11
+ if (!/^[a-z]+-[a-f0-9]{64}\.json$/.test(name))
12
+ throw new Error("Invalid plugin storage key");
13
+ for (const directory of [userDir, folder]) {
14
+ const entry = lstatSync(directory, { throwIfNoEntry: false });
15
+ if (entry &&
16
+ (!entry.isDirectory() ||
17
+ entry.isSymbolicLink() ||
18
+ (process.platform !== "win32" &&
19
+ ((entry.mode & 0o022) !== 0 || entry.uid !== process.getuid?.()))))
20
+ throw new Error("Personal plugin storage must be a local directory");
21
+ }
22
+ return join(folder, name);
23
+ };
24
+ const read = (name) => {
25
+ const file = path(name);
26
+ const entry = lstatSync(file, { throwIfNoEntry: false });
27
+ if (!entry)
28
+ return undefined;
29
+ if (!entry.isFile() ||
30
+ entry.isSymbolicLink() ||
31
+ entry.size > 1_048_576 ||
32
+ (process.platform !== "win32" &&
33
+ ((entry.mode & 0o077) !== 0 || entry.uid !== process.getuid?.())))
34
+ throw new Error("Personal plugin files must be private to the current user");
35
+ return JSON.parse(readFileSync(file, "utf8"));
36
+ };
37
+ const write = (name, value) => {
38
+ const file = path(name);
39
+ mkdirSync(folder, { recursive: true, mode: 0o700 });
40
+ const temporary = `${file}.${randomBytes(8).toString("hex")}.tmp`;
41
+ try {
42
+ writeFileSync(temporary, JSON.stringify(value), { flag: "wx", mode: 0o600 });
43
+ renameSync(temporary, file);
44
+ }
45
+ finally {
46
+ rmSync(temporary, { force: true });
47
+ }
48
+ };
49
+ const key = (plugin) => `credentials-${createHash("sha256")
50
+ .update(JSON.stringify([
51
+ plugin.id,
52
+ plugin.oauth.authorizationUrl,
53
+ plugin.oauth.tokenUrl,
54
+ plugin.oauth.clientId,
55
+ plugin.oauth.clientSecret ?? "",
56
+ [...plugin.oauth.scopes].sort(),
57
+ [...plugin.apiOrigins].sort(),
58
+ plugin.oauth.authorizationParams ?? {},
59
+ ]))
60
+ .digest("hex")}.json`;
61
+ const credentials = {
62
+ get(plugin) {
63
+ const value = read(key(plugin));
64
+ if (!value)
65
+ return undefined;
66
+ const token = parseOAuthToken(value.token);
67
+ if (!value.user ||
68
+ typeof value.user.id !== "string" ||
69
+ !value.user.id ||
70
+ value.user.id.length > 512 ||
71
+ typeof value.user.name !== "string" ||
72
+ !value.user.name ||
73
+ value.user.name.length > 512 ||
74
+ (value.expires !== null &&
75
+ (typeof value.expires !== "number" || !Number.isFinite(value.expires))))
76
+ throw new Error("Invalid saved plugin login");
77
+ return { token, user: value.user, expires: value.expires ?? Infinity };
78
+ },
79
+ set(plugin, value) {
80
+ write(key(plugin), value ?? null);
81
+ },
82
+ async withLock(plugin, run) {
83
+ const lock = `${path(key(plugin))}.lock`;
84
+ mkdirSync(folder, { recursive: true, mode: 0o700 });
85
+ const owner = randomBytes(16).toString("hex");
86
+ const deadline = Date.now() + 20000;
87
+ while (true) {
88
+ try {
89
+ writeFileSync(lock, owner, { flag: "wx", mode: 0o600 });
90
+ break;
91
+ }
92
+ catch (error) {
93
+ if (error.code !== "EEXIST")
94
+ throw error;
95
+ const entry = lstatSync(lock, { throwIfNoEntry: false });
96
+ if (entry?.isSymbolicLink())
97
+ throw new Error("Invalid login lock");
98
+ if (entry && Date.now() - entry.mtimeMs > 30000)
99
+ rmSync(lock, { force: true });
100
+ if (Date.now() >= deadline)
101
+ throw new Error("Another dooc process is refreshing this login. Try again.");
102
+ await delay(50);
103
+ }
104
+ }
105
+ try {
106
+ return await run();
107
+ }
108
+ finally {
109
+ if (readFileSync(lock, "utf8") === owner)
110
+ rmSync(lock, { force: true });
111
+ }
112
+ },
113
+ };
114
+ return { read, write, credentials };
115
+ };
@@ -1,3 +1,4 @@
1
+ import { validPluginIcon } from "./blocks.js";
1
2
  export class PluginError extends Error {
2
3
  status;
3
4
  name = "DoocPluginError";
@@ -112,6 +113,11 @@ export const validatePlugins = (input) => {
112
113
  ids.add(plugin.id);
113
114
  const oauth = plugin.oauth;
114
115
  if (!oauth ||
116
+ (plugin.name !== undefined &&
117
+ (typeof plugin.name !== "string" ||
118
+ !plugin.name.trim() ||
119
+ plugin.name.length > 100)) ||
120
+ (plugin.icon !== undefined && !validPluginIcon(plugin.icon)) ||
115
121
  typeof oauth.clientId !== "string" ||
116
122
  !oauth.clientId.trim() ||
117
123
  (oauth.clientSecret !== undefined && typeof oauth.clientSecret !== "string") ||
@@ -1,6 +1,7 @@
1
1
  import { type PluginActionResult } from "./actions.js";
2
+ import type { PluginCredentialStore } from "./local-storage.js";
2
3
  import type { DataPlugin, PluginUser } from "./types.js";
3
- export declare const createPluginRuntime: (definitions: readonly DataPlugin[], transport?: typeof fetch) => {
4
+ export declare const createPluginRuntime: (definitions: readonly DataPlugin[], transport?: typeof fetch, credentials?: PluginCredentialStore) => {
4
5
  resolve(source: unknown): string | undefined;
5
6
  register(plugin: DataPlugin): void;
6
7
  unregister: (pluginId: string) => void;
@@ -9,6 +10,7 @@ export declare const createPluginRuntime: (definitions: readonly DataPlugin[], t
9
10
  connect(id: string, pluginId: string, redirectUri: string): string;
10
11
  callback(id: string, pluginId: string, redirectUri: string, params: URLSearchParams): Promise<void>;
11
12
  disconnect(id: string, pluginId: string): void;
13
+ status(id: string, pluginId: string): PluginUser | undefined;
12
14
  read(id: string, pluginId: string, source: unknown): Promise<{
13
15
  user: PluginUser;
14
16
  view: import("./view.js").PluginView;
@@ -5,9 +5,9 @@ import { parsePluginUrl, validatePluginView } from "./view.js";
5
5
  const nonce = () => randomBytes(32).toString("base64url");
6
6
  const expired = () => new PluginError(401, "Connect this plugin again");
7
7
  const expiry = (token) => token.expires_in === undefined ? Infinity : Date.now() + token.expires_in * 1000;
8
- export const createPluginRuntime = (definitions, transport) => {
8
+ export const createPluginRuntime = (definitions, transport, credentials) => {
9
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.
10
+ // Browser sessions expire independently of optional personal credential persistence.
11
11
  const sessions = new Map();
12
12
  const sessionFor = (id) => {
13
13
  const session = sessions.get(id);
@@ -96,27 +96,62 @@ export const createPluginRuntime = (definitions, transport) => {
96
96
  const plugin = pluginFor(pluginId);
97
97
  if (!plugin.matches(url))
98
98
  throw new PluginError(400, "URL is not supported by this plugin");
99
- const connection = session.connections.get(pluginId);
100
- if (!connection)
99
+ let restored = session.connections.get(pluginId);
100
+ if (credentials) {
101
+ const saved = credentials.get(plugin);
102
+ if (!saved) {
103
+ session.connections.delete(pluginId);
104
+ throw expired();
105
+ }
106
+ if (!restored || restored.user.id !== saved.user.id) {
107
+ restored = saved;
108
+ session.connections.set(pluginId, restored);
109
+ }
110
+ else if (restored.token.access_token !== saved.token.access_token) {
111
+ restored.token = saved.token;
112
+ restored.expires = saved.expires;
113
+ }
114
+ }
115
+ if (!restored)
101
116
  throw expired();
117
+ const connection = restored;
102
118
  try {
103
119
  if (connection.expires <= Date.now() + 30_000) {
104
120
  const refreshToken = connection.token.refresh_token;
105
121
  if (!refreshToken)
106
122
  throw expired();
107
123
  connection.refreshing ??= (async () => {
108
- try {
124
+ const refresh = async () => {
125
+ const saved = credentials?.get(plugin);
126
+ if (credentials && (!saved || saved.user.id !== connection.user.id))
127
+ throw expired();
128
+ if (saved && saved.token.access_token !== connection.token.access_token) {
129
+ connection.token = saved.token;
130
+ connection.expires = saved.expires;
131
+ if (connection.expires > Date.now() + 30_000)
132
+ return;
133
+ }
134
+ const previous = connection.token.access_token;
135
+ const usedRefresh = connection.token.refresh_token ?? refreshToken;
109
136
  const token = await exchange(plugin, {
110
137
  grant_type: "refresh_token",
111
- refresh_token: refreshToken,
138
+ refresh_token: usedRefresh,
112
139
  });
113
140
  assertCurrent(id, pluginId, connection);
141
+ if (credentials && credentials.get(plugin)?.token.access_token !== previous)
142
+ throw expired();
114
143
  connection.token = {
115
144
  ...token,
116
- refresh_token: token.refresh_token ?? refreshToken,
145
+ refresh_token: token.refresh_token ?? usedRefresh,
117
146
  scope: token.scope ?? connection.token.scope,
118
147
  };
119
148
  connection.expires = expiry(token);
149
+ credentials?.set(plugin, connection);
150
+ };
151
+ try {
152
+ await (credentials?.withLock
153
+ ? credentials.withLock(plugin, refresh)
154
+ : refresh());
120
155
  }
121
156
  finally {
122
157
  connection.refreshing = undefined;
@@ -132,8 +167,12 @@ export const createPluginRuntime = (definitions, transport) => {
132
167
  catch (error) {
133
168
  if (isPluginError(error) &&
134
169
  error.status === 401 &&
135
- session.connections.get(pluginId) === connection)
170
+ session.connections.get(pluginId) === connection) {
136
171
  session.connections.delete(pluginId);
172
+ const saved = credentials?.get(plugin);
173
+ if (saved?.token.access_token === connection.token.access_token)
174
+ credentials?.set(plugin);
175
+ }
137
176
  throw error;
138
177
  }
139
178
  };
@@ -196,6 +235,10 @@ export const createPluginRuntime = (definitions, transport) => {
196
235
  expires: Date.now() + 10 * 60 * 1000,
197
236
  };
198
237
  session.connections.delete(pluginId);
238
+ credentials?.set(plugin);
239
+ if (credentials)
240
+ for (const other of sessions.values())
241
+ other.connections.delete(pluginId);
199
242
  session.attempts.set(pluginId, attempt);
200
243
  const url = new URL(plugin.oauth.authorizationUrl);
201
244
  for (const [key, value] of Object.entries(plugin.oauth.authorizationParams ?? {}))
@@ -246,16 +289,29 @@ export const createPluginRuntime = (definitions, transport) => {
246
289
  if (sessionFor(id).attempts.get(pluginId) !== attempt)
247
290
  throw expired();
248
291
  session.attempts.delete(pluginId);
249
- session.connections.set(pluginId, {
250
- token,
251
- expires,
252
- user: { id: user.id, name: user.name },
253
- });
292
+ const connection = { token, expires, user: { id: user.id, name: user.name } };
293
+ credentials?.set(plugin, connection);
294
+ session.connections.set(pluginId, connection);
254
295
  },
255
296
  disconnect(id, pluginId) {
256
297
  const session = sessionFor(id);
257
298
  session.connections.delete(pluginId);
258
299
  session.attempts.delete(pluginId);
300
+ credentials?.set(pluginFor(pluginId));
301
+ if (credentials)
302
+ for (const other of sessions.values())
303
+ other.connections.delete(pluginId);
304
+ },
305
+ status(id, pluginId) {
306
+ const plugin = pluginFor(pluginId);
307
+ const session = sessionFor(id);
308
+ const connection = credentials
309
+ ? credentials.get(plugin)
310
+ : session.connections.get(pluginId);
311
+ if (!connection ||
312
+ (connection.expires <= Date.now() && !connection.token.refresh_token))
313
+ return undefined;
314
+ return { ...connection.user };
259
315
  },
260
316
  read(id, pluginId, source) {
261
317
  return access(id, pluginId, source, async (plugin, url, connection) => ({
@@ -32,10 +32,13 @@ export interface PluginContext {
32
32
  }
33
33
  export type { PluginContent, PluginView } from "./view.js";
34
34
  import type { PluginAction, PluginActionContext, PluginActionRequest, PluginActionResult } from "./actions.js";
35
+ import type { PluginIcon } from "./blocks.js";
35
36
  import type { PluginView } from "./view.js";
36
37
  /** Trusted server code injected into the viewer, independently of document configuration. */
37
38
  export interface DataPlugin {
38
39
  id: string;
40
+ name?: string;
41
+ icon?: PluginIcon;
39
42
  /** Match a service URL without fetching it; unmatched embeds remain ordinary links. */
40
43
  matches: (url: URL) => boolean;
41
44
  oauth: PluginOAuth;
@@ -1,27 +1,5 @@
1
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
- };
2
+ export type PluginContent = PluginBlockContent;
25
3
  export type PluginView = PluginContent & {
26
4
  collapsed?: PluginContent;
27
5
  };
@@ -1,44 +1,4 @@
1
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
2
  export const validatePluginView = (input) => {
43
3
  const encoded = JSON.stringify(input);
44
4
  if (typeof encoded !== "string")
@@ -46,7 +6,8 @@ export const validatePluginView = (input) => {
46
6
  if (new TextEncoder().encode(encoded).length > 2 * 1024 * 1024)
47
7
  throw new Error("Plugin view is too large");
48
8
  const view = JSON.parse(encoded);
49
- if (!validContent(view) || (view.collapsed !== undefined && !validContent(view.collapsed)))
9
+ if (!validPluginBlocks(view) ||
10
+ (view.collapsed !== undefined && !validPluginBlocks(view.collapsed)))
50
11
  throw new Error("Invalid plugin view");
51
12
  return view;
52
13
  };