nextpluggable 0.0.0-stage → 0.1.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 +288 -2
- package/bin/nextpluggable.js +10 -0
- package/dist/api/handler.d.ts +10 -0
- package/dist/api/handler.js +188 -0
- package/dist/auth/custom.d.ts +13 -0
- package/dist/auth/custom.js +12 -0
- package/dist/auth/password.d.ts +10 -0
- package/dist/auth/password.js +66 -0
- package/dist/auth/types.d.ts +23 -0
- package/dist/auth/types.js +1 -0
- package/dist/cli.d.ts +9 -0
- package/dist/cli.js +123 -0
- package/dist/client/host-runtime.d.ts +9 -0
- package/dist/client/host-runtime.js +71 -0
- package/dist/client/island.d.ts +28 -0
- package/dist/client/island.js +177 -0
- package/dist/client/ssr-guard.d.ts +11 -0
- package/dist/client/ssr-guard.js +100 -0
- package/dist/config.d.ts +55 -0
- package/dist/config.js +57 -0
- package/dist/core/installer.d.ts +30 -0
- package/dist/core/installer.js +340 -0
- package/dist/core/routes.d.ts +20 -0
- package/dist/core/routes.js +95 -0
- package/dist/core/runtime.d.ts +34 -0
- package/dist/core/runtime.js +195 -0
- package/dist/core/sitemap.d.ts +12 -0
- package/dist/core/sitemap.js +83 -0
- package/dist/core/state.d.ts +13 -0
- package/dist/core/state.js +23 -0
- package/dist/core/store.d.ts +17 -0
- package/dist/core/store.js +58 -0
- package/dist/core/types.d.ts +69 -0
- package/dist/core/types.js +1 -0
- package/dist/crypto.d.ts +14 -0
- package/dist/crypto.js +61 -0
- package/dist/dashboard/Dashboard.d.ts +11 -0
- package/dist/dashboard/Dashboard.js +324 -0
- package/dist/dashboard/styles.d.ts +2 -0
- package/dist/dashboard/styles.js +129 -0
- package/dist/dashboard/ui.d.ts +61 -0
- package/dist/dashboard/ui.js +135 -0
- package/dist/errors.d.ts +15 -0
- package/dist/errors.js +29 -0
- package/dist/globals.d.ts +24 -0
- package/dist/globals.js +24 -0
- package/dist/index.d.ts +47 -0
- package/dist/index.js +65 -0
- package/dist/next/dashboard-page.d.ts +5 -0
- package/dist/next/dashboard-page.js +23 -0
- package/dist/next/plugin-page.d.ts +16 -0
- package/dist/next/plugin-page.js +91 -0
- package/dist/next/seo.d.ts +32 -0
- package/dist/next/seo.js +86 -0
- package/dist/next/slot.d.ts +7 -0
- package/dist/next/slot.js +43 -0
- package/dist/next/style.d.ts +10 -0
- package/dist/next/style.js +13 -0
- package/dist/sources/github.d.ts +19 -0
- package/dist/sources/github.js +109 -0
- package/dist/sources/local.d.ts +3 -0
- package/dist/sources/local.js +22 -0
- package/dist/sources/reader.d.ts +17 -0
- package/dist/sources/reader.js +78 -0
- package/dist/storage/file.d.ts +12 -0
- package/dist/storage/file.js +61 -0
- package/dist/storage/memory.d.ts +3 -0
- package/dist/storage/memory.js +22 -0
- package/dist/storage/postgres.d.ts +15 -0
- package/dist/storage/postgres.js +39 -0
- package/dist/storage/types.d.ts +18 -0
- package/dist/storage/types.js +15 -0
- package/dist/storage/upstash.d.ts +15 -0
- package/dist/storage/upstash.js +61 -0
- package/dist/views.d.ts +112 -0
- package/dist/views.js +124 -0
- package/package.json +48 -3
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { defaultConfig, isPublicSetting } from '@nextpluggable/plugin/manifest';
|
|
2
|
+
import { decrypt, isEncrypted } from '../crypto.js';
|
|
3
|
+
import { jsonResponse, PluginGoneError } from '../errors.js';
|
|
4
|
+
import { globals } from '../globals.js';
|
|
5
|
+
import { decodeSegment } from '../storage/types.js';
|
|
6
|
+
import { effectivePaths, matchPattern, normalizePathname } from './routes.js';
|
|
7
|
+
import { dataKey, getFile } from './store.js';
|
|
8
|
+
/** Identifies one exact build of a plugin's client bundle (used on both server and browser). */
|
|
9
|
+
export function clientModuleKey(install) {
|
|
10
|
+
return install.files.client ? `${install.id}@${install.files.client.sha256.slice(0, 16)}` : null;
|
|
11
|
+
}
|
|
12
|
+
export function assetUrl(cfg, pluginId, sha256, file) {
|
|
13
|
+
return `${cfg.apiPath}/assets/${pluginId}/${sha256.slice(0, 16)}/${file}`;
|
|
14
|
+
}
|
|
15
|
+
/** Setting values safe to send to browsers, with defaults filled in. */
|
|
16
|
+
export function publicConfig(install) {
|
|
17
|
+
const out = {};
|
|
18
|
+
const defaults = defaultConfig(install.manifest);
|
|
19
|
+
for (const field of install.manifest.settings ?? []) {
|
|
20
|
+
if (!isPublicSetting(field))
|
|
21
|
+
continue;
|
|
22
|
+
const v = install.config[field.key] ?? defaults[field.key];
|
|
23
|
+
if (v !== undefined)
|
|
24
|
+
out[field.key] = v;
|
|
25
|
+
}
|
|
26
|
+
return out;
|
|
27
|
+
}
|
|
28
|
+
/** Every setting value, secrets decrypted. Server-side only. */
|
|
29
|
+
export function serverConfig(cfg, install) {
|
|
30
|
+
const out = { ...defaultConfig(install.manifest) };
|
|
31
|
+
for (const field of install.manifest.settings ?? []) {
|
|
32
|
+
const v = install.config[field.key];
|
|
33
|
+
if (v === undefined)
|
|
34
|
+
continue;
|
|
35
|
+
if (isEncrypted(v)) {
|
|
36
|
+
const plain = decrypt(cfg.secret, v);
|
|
37
|
+
if (plain !== null)
|
|
38
|
+
out[field.key] = plain;
|
|
39
|
+
}
|
|
40
|
+
else
|
|
41
|
+
out[field.key] = v;
|
|
42
|
+
}
|
|
43
|
+
return out;
|
|
44
|
+
}
|
|
45
|
+
export function pluginStorage(cfg, pluginId) {
|
|
46
|
+
const prefix = `data/${pluginId}/`;
|
|
47
|
+
return {
|
|
48
|
+
async get(key) {
|
|
49
|
+
const raw = await cfg.storage.get(dataKey(pluginId, key));
|
|
50
|
+
return raw === null ? null : JSON.parse(raw);
|
|
51
|
+
},
|
|
52
|
+
async set(key, value) {
|
|
53
|
+
const raw = JSON.stringify(value);
|
|
54
|
+
if (raw === undefined)
|
|
55
|
+
throw new Error('Only JSON values can be stored.');
|
|
56
|
+
if (raw.length > 1_000_000)
|
|
57
|
+
throw new Error('A stored value may be at most 1 MB.');
|
|
58
|
+
await cfg.storage.set(dataKey(pluginId, key), raw);
|
|
59
|
+
},
|
|
60
|
+
async delete(key) {
|
|
61
|
+
await cfg.storage.delete(dataKey(pluginId, key));
|
|
62
|
+
},
|
|
63
|
+
async list(keyPrefix = '') {
|
|
64
|
+
const keys = await cfg.storage.list(prefix);
|
|
65
|
+
return keys
|
|
66
|
+
.map((k) => decodeSegment(k.slice(prefix.length)))
|
|
67
|
+
.filter((k) => k.startsWith(keyPrefix))
|
|
68
|
+
.sort();
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
export function pluginContext(cfg, install) {
|
|
73
|
+
return {
|
|
74
|
+
pluginId: install.id,
|
|
75
|
+
config: serverConfig(cfg, install),
|
|
76
|
+
storage: pluginStorage(cfg, install.id),
|
|
77
|
+
site: { url: cfg.siteUrl },
|
|
78
|
+
paths: effectivePaths(install),
|
|
79
|
+
log: (...args) => console.log(`[plugin:${install.id}]`, ...args),
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
/** Evaluate a plugin's server bundle (CommonJS, self-contained) and return what it exports. */
|
|
83
|
+
export function evaluateServer(pluginId, code) {
|
|
84
|
+
const getBuiltin = process.getBuiltinModule;
|
|
85
|
+
if (!getBuiltin)
|
|
86
|
+
throw new Error('Plugins with server code need Node.js 20.16 or newer.');
|
|
87
|
+
const mod = { exports: {} };
|
|
88
|
+
const req = (name) => {
|
|
89
|
+
const builtin = getBuiltin(name);
|
|
90
|
+
if (!builtin)
|
|
91
|
+
throw new Error(`Plugin "${pluginId}" tried to load "${name}", which is not available. Server bundles may only use Node built-ins at runtime.`);
|
|
92
|
+
return builtin;
|
|
93
|
+
};
|
|
94
|
+
// Plugins are trusted code chosen by the site owner; this is deliberate evaluation, not a sandbox.
|
|
95
|
+
new Function('require', 'module', 'exports', `${code}\n//# sourceURL=nextpluggable-plugin:///${pluginId}/server.js`)(req, mod, mod.exports);
|
|
96
|
+
const server = (mod.exports.default ?? mod.exports);
|
|
97
|
+
if (!server || typeof server !== 'object')
|
|
98
|
+
throw new Error(`Plugin "${pluginId}" has an invalid server entry.`);
|
|
99
|
+
return server;
|
|
100
|
+
}
|
|
101
|
+
/** The plugin's server module, or null when it has none. Cached by content hash. */
|
|
102
|
+
export async function loadServer(cfg, install) {
|
|
103
|
+
const file = install.files.server;
|
|
104
|
+
if (!file)
|
|
105
|
+
return null;
|
|
106
|
+
const cache = globals().serverModules;
|
|
107
|
+
const key = `${install.id}@${file.sha256}`;
|
|
108
|
+
const hit = cache.get(key);
|
|
109
|
+
if (hit)
|
|
110
|
+
return hit;
|
|
111
|
+
const code = await getFile(cfg.storage, install.id, 'server', file.sha256);
|
|
112
|
+
if (code === null)
|
|
113
|
+
throw new PluginGoneError(install.id);
|
|
114
|
+
const server = evaluateServer(install.id, code);
|
|
115
|
+
for (const k of cache.keys())
|
|
116
|
+
if (k.startsWith(`${install.id}@`))
|
|
117
|
+
cache.delete(k);
|
|
118
|
+
cache.set(key, server);
|
|
119
|
+
return server;
|
|
120
|
+
}
|
|
121
|
+
/** Larger stylesheets are linked: inlining them would bloat every page that shows the plugin. */
|
|
122
|
+
const INLINE_STYLE_MAX_BYTES = 32_000;
|
|
123
|
+
/**
|
|
124
|
+
* Make a plugin's client bundle available to the SSR pass of this process and
|
|
125
|
+
* return the URLs the browser loads it from.
|
|
126
|
+
*/
|
|
127
|
+
export async function prepareClient(cfg, install) {
|
|
128
|
+
const client = install.files.client;
|
|
129
|
+
const moduleKey = clientModuleKey(install);
|
|
130
|
+
if (!client || !moduleKey)
|
|
131
|
+
return null;
|
|
132
|
+
const ssr = globals().ssrCode;
|
|
133
|
+
if (!ssr.has(moduleKey)) {
|
|
134
|
+
const code = await getFile(cfg.storage, install.id, 'client', client.sha256);
|
|
135
|
+
if (code === null)
|
|
136
|
+
throw new PluginGoneError(install.id);
|
|
137
|
+
for (const k of ssr.keys())
|
|
138
|
+
if (k.startsWith(`${install.id}@`))
|
|
139
|
+
ssr.delete(k);
|
|
140
|
+
ssr.set(moduleKey, code);
|
|
141
|
+
}
|
|
142
|
+
const style = install.files.style;
|
|
143
|
+
const styleCss = style && cfg.inlineStyles && style.bytes <= INLINE_STYLE_MAX_BYTES ? ((await getFile(cfg.storage, install.id, 'style', style.sha256)) ?? undefined) : undefined;
|
|
144
|
+
return {
|
|
145
|
+
moduleKey,
|
|
146
|
+
scriptSrc: assetUrl(cfg, install.id, client.sha256, 'client.js'),
|
|
147
|
+
styleHref: style ? assetUrl(cfg, install.id, style.sha256, 'style.css') : undefined,
|
|
148
|
+
styleCss,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
export async function runPageLoader(cfg, install, pageId, input) {
|
|
152
|
+
const server = await loadServer(cfg, install);
|
|
153
|
+
const loader = server?.pages?.[pageId];
|
|
154
|
+
if (!loader)
|
|
155
|
+
return {};
|
|
156
|
+
const ctx = { ...pluginContext(cfg, install), ...input };
|
|
157
|
+
const result = await loader(ctx);
|
|
158
|
+
return result && typeof result === 'object' ? result : {};
|
|
159
|
+
}
|
|
160
|
+
/** Dispatch a request to a plugin's API handlers (`"METHOD /path"` keys). */
|
|
161
|
+
export async function handlePluginApi(cfg, install, request, subpath, admin) {
|
|
162
|
+
let server;
|
|
163
|
+
try {
|
|
164
|
+
server = await loadServer(cfg, install);
|
|
165
|
+
}
|
|
166
|
+
catch (e) {
|
|
167
|
+
console.error(`[nextpluggable] Could not load plugin "${install.id}":`, e);
|
|
168
|
+
return jsonResponse({ error: 'This plugin could not be loaded.' }, 500);
|
|
169
|
+
}
|
|
170
|
+
const path = normalizePathname(subpath);
|
|
171
|
+
const method = request.method.toUpperCase() === 'HEAD' ? 'GET' : request.method.toUpperCase();
|
|
172
|
+
let methodMismatch = false;
|
|
173
|
+
for (const [key, handler] of Object.entries(server?.api ?? {})) {
|
|
174
|
+
const space = key.indexOf(' ');
|
|
175
|
+
const params = matchPattern(key.slice(space + 1), path);
|
|
176
|
+
if (!params)
|
|
177
|
+
continue;
|
|
178
|
+
if (key.slice(0, space) !== method) {
|
|
179
|
+
methodMismatch = true;
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
const ctx = { ...pluginContext(cfg, install), request, path, params, admin };
|
|
183
|
+
try {
|
|
184
|
+
const res = await handler(ctx);
|
|
185
|
+
if (!res || typeof res.status !== 'number' || typeof res.headers?.get !== 'function')
|
|
186
|
+
throw new Error(`Handler "${key}" did not return a Response.`);
|
|
187
|
+
return res;
|
|
188
|
+
}
|
|
189
|
+
catch (e) {
|
|
190
|
+
console.error(`[nextpluggable] Plugin "${install.id}" failed handling ${key}:`, e);
|
|
191
|
+
return jsonResponse({ error: 'The plugin ran into a problem handling this request.' }, 500);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return methodMismatch ? jsonResponse({ error: 'Method not allowed.' }, 405) : jsonResponse({ error: 'Not found.' }, 404);
|
|
195
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { ResolvedConfig } from '../config.js';
|
|
2
|
+
export interface PluginSitemapEntry {
|
|
3
|
+
url: string;
|
|
4
|
+
lastModified?: Date;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Every address that switched-on plugins serve and that search engines may index, for
|
|
8
|
+
* the site's sitemap. Never throws: a plugin that fails while listing its pages keeps
|
|
9
|
+
* what it had listed so far, and a storage outage yields an empty list rather than a
|
|
10
|
+
* broken sitemap.
|
|
11
|
+
*/
|
|
12
|
+
export declare function pluginSitemap(cfg: ResolvedConfig): Promise<PluginSitemapEntry[]>;
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { isStaticPath } from '@nextpluggable/plugin/manifest';
|
|
2
|
+
import { effectivePaths, findPage, normalizePathname } from './routes.js';
|
|
3
|
+
import { loadServer, pluginContext, runPageLoader } from './runtime.js';
|
|
4
|
+
import { getInstalledPlugins } from './state.js';
|
|
5
|
+
/** A plugin may list this many addresses; a sitemap file holds 50,000 in all. */
|
|
6
|
+
const MAX_PER_PLUGIN = 10_000;
|
|
7
|
+
const toDate = (value) => {
|
|
8
|
+
if (typeof value !== 'string' && !(value instanceof Date))
|
|
9
|
+
return undefined;
|
|
10
|
+
const date = new Date(value);
|
|
11
|
+
return Number.isNaN(date.getTime()) ? undefined : date;
|
|
12
|
+
};
|
|
13
|
+
async function entriesOf(cfg, install, add) {
|
|
14
|
+
const paths = effectivePaths(install);
|
|
15
|
+
const updated = toDate(install.updatedAt);
|
|
16
|
+
// Pages with a fixed address. The loader decides whether the page exists and may be indexed.
|
|
17
|
+
for (const page of install.manifest.pages ?? []) {
|
|
18
|
+
const address = paths[page.id];
|
|
19
|
+
if (!isStaticPath(address))
|
|
20
|
+
continue;
|
|
21
|
+
const result = await runPageLoader(cfg, install, page.id, { path: address, params: {} });
|
|
22
|
+
if (result.notFound || result.redirect || result.metadata?.noindex)
|
|
23
|
+
continue;
|
|
24
|
+
const canonical = result.metadata?.canonical;
|
|
25
|
+
// A page that names another address as the real one is not listed under its own.
|
|
26
|
+
if (canonical && new URL(canonical, `${cfg.siteUrl}/`).pathname !== address)
|
|
27
|
+
continue;
|
|
28
|
+
add(address, toDate(result.metadata?.modifiedTime) ?? updated);
|
|
29
|
+
}
|
|
30
|
+
// Pages with parameters: only the plugin knows which addresses exist.
|
|
31
|
+
const server = await loadServer(cfg, install);
|
|
32
|
+
if (typeof server?.sitemap !== 'function')
|
|
33
|
+
return;
|
|
34
|
+
const listed = await server.sitemap(pluginContext(cfg, install));
|
|
35
|
+
if (!Array.isArray(listed))
|
|
36
|
+
return;
|
|
37
|
+
const own = { [install.id]: install };
|
|
38
|
+
for (const entry of listed.slice(0, MAX_PER_PLUGIN)) {
|
|
39
|
+
const raw = typeof entry === 'string' ? entry : entry?.path;
|
|
40
|
+
if (typeof raw !== 'string' || !raw.startsWith('/'))
|
|
41
|
+
continue;
|
|
42
|
+
const address = normalizePathname(raw);
|
|
43
|
+
// A plugin can only add addresses of its own pages.
|
|
44
|
+
if (!findPage(own, address))
|
|
45
|
+
continue;
|
|
46
|
+
add(address, typeof entry === 'string' ? undefined : toDate(entry.lastModified));
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Every address that switched-on plugins serve and that search engines may index, for
|
|
51
|
+
* the site's sitemap. Never throws: a plugin that fails while listing its pages keeps
|
|
52
|
+
* what it had listed so far, and a storage outage yields an empty list rather than a
|
|
53
|
+
* broken sitemap.
|
|
54
|
+
*/
|
|
55
|
+
export async function pluginSitemap(cfg) {
|
|
56
|
+
const out = new Map();
|
|
57
|
+
try {
|
|
58
|
+
for (const install of Object.values(await getInstalledPlugins(cfg))) {
|
|
59
|
+
if (!install.enabled)
|
|
60
|
+
continue;
|
|
61
|
+
try {
|
|
62
|
+
await entriesOf(cfg, install, (address, lastModified) => {
|
|
63
|
+
let url;
|
|
64
|
+
try {
|
|
65
|
+
url = new URL(address, `${cfg.siteUrl}/`).toString();
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
if (!out.has(url))
|
|
71
|
+
out.set(url, lastModified ? { url, lastModified } : { url });
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
catch (e) {
|
|
75
|
+
console.error(`[nextpluggable] Plugin "${install.id}" could not list all of its pages for the sitemap:`, e);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
catch (e) {
|
|
80
|
+
console.error('[nextpluggable] Plugin pages could not be listed for the sitemap:', e);
|
|
81
|
+
}
|
|
82
|
+
return [...out.values()];
|
|
83
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { ResolvedConfig } from '../config.js';
|
|
2
|
+
import type { Installs } from './types.js';
|
|
3
|
+
/**
|
|
4
|
+
* Installed plugins, read through a short-lived per-process cache so that a
|
|
5
|
+
* page render costs at most one storage read every `cacheTtlMs`.
|
|
6
|
+
*
|
|
7
|
+
* The cache lives on `globalThis` and is keyed by the instance's API path, not
|
|
8
|
+
* by any object identity: Next.js loads the site's setup module once per module
|
|
9
|
+
* graph (pages, route handlers), and a change made through the dashboard's
|
|
10
|
+
* route handler must be visible to the very next page render.
|
|
11
|
+
*/
|
|
12
|
+
export declare function getInstalledPlugins(cfg: ResolvedConfig): Promise<Installs>;
|
|
13
|
+
export declare function invalidateInstalledPlugins(cfg: ResolvedConfig): void;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { globals } from '../globals.js';
|
|
2
|
+
import { getInstalls } from './store.js';
|
|
3
|
+
/**
|
|
4
|
+
* Installed plugins, read through a short-lived per-process cache so that a
|
|
5
|
+
* page render costs at most one storage read every `cacheTtlMs`.
|
|
6
|
+
*
|
|
7
|
+
* The cache lives on `globalThis` and is keyed by the instance's API path, not
|
|
8
|
+
* by any object identity: Next.js loads the site's setup module once per module
|
|
9
|
+
* graph (pages, route handlers), and a change made through the dashboard's
|
|
10
|
+
* route handler must be visible to the very next page render.
|
|
11
|
+
*/
|
|
12
|
+
export async function getInstalledPlugins(cfg) {
|
|
13
|
+
const cache = globals().state;
|
|
14
|
+
const hit = cache.get(cfg.apiPath);
|
|
15
|
+
if (hit && Date.now() - hit.at < cfg.cacheTtlMs)
|
|
16
|
+
return hit.value;
|
|
17
|
+
const value = await getInstalls(cfg.storage);
|
|
18
|
+
cache.set(cfg.apiPath, { at: Date.now(), value });
|
|
19
|
+
return value;
|
|
20
|
+
}
|
|
21
|
+
export function invalidateInstalledPlugins(cfg) {
|
|
22
|
+
globals().state.delete(cfg.apiPath);
|
|
23
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { type StorageAdapter } from '../storage/types.js';
|
|
2
|
+
import type { Catalog, FileKind, Installs, SourceRecord, StoredFile } from './types.js';
|
|
3
|
+
export declare const getSources: (s: StorageAdapter) => Promise<SourceRecord[]>;
|
|
4
|
+
export declare const saveSources: (s: StorageAdapter, sources: SourceRecord[]) => Promise<void>;
|
|
5
|
+
export declare const getInstalls: (s: StorageAdapter) => Promise<Installs>;
|
|
6
|
+
export declare const saveInstalls: (s: StorageAdapter, installs: Installs) => Promise<void>;
|
|
7
|
+
export declare const getCatalog: (s: StorageAdapter, sourceId: string) => Promise<Catalog | null>;
|
|
8
|
+
export declare const saveCatalog: (s: StorageAdapter, catalog: Catalog) => Promise<void>;
|
|
9
|
+
export declare const deleteCatalog: (s: StorageAdapter, sourceId: string) => Promise<void>;
|
|
10
|
+
export declare const fileKey: (pluginId: string, kind: FileKind, sha256: string) => string;
|
|
11
|
+
export declare function putFile(s: StorageAdapter, pluginId: string, kind: FileKind, sha256: string, content: string): Promise<void>;
|
|
12
|
+
/** Plugin files are content-addressed and never change, so they are cached in memory for the life of the process. */
|
|
13
|
+
export declare function getFile(s: StorageAdapter, pluginId: string, kind: FileKind, sha256: string): Promise<string | null>;
|
|
14
|
+
/** Delete stored files of a plugin except the ones listed. */
|
|
15
|
+
export declare function pruneFiles(s: StorageAdapter, pluginId: string, keep: Partial<Record<FileKind, StoredFile>>[]): Promise<void>;
|
|
16
|
+
export declare const dataKey: (pluginId: string, key: string) => string;
|
|
17
|
+
export declare function deletePluginData(s: StorageAdapter, pluginId: string): Promise<void>;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { globals } from '../globals.js';
|
|
2
|
+
import { encodeSegment } from '../storage/types.js';
|
|
3
|
+
const FILE_NAMES = { client: 'client.js', server: 'server.js', style: 'style.css' };
|
|
4
|
+
async function readJson(storage, key, fallback) {
|
|
5
|
+
const raw = await storage.get(key);
|
|
6
|
+
if (raw === null)
|
|
7
|
+
return fallback;
|
|
8
|
+
try {
|
|
9
|
+
return JSON.parse(raw);
|
|
10
|
+
}
|
|
11
|
+
catch {
|
|
12
|
+
throw new Error(`[nextpluggable] Stored record "${key}" is corrupted.`);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
export const getSources = (s) => readJson(s, 'sources.json', []);
|
|
16
|
+
export const saveSources = (s, sources) => s.set('sources.json', JSON.stringify(sources));
|
|
17
|
+
export const getInstalls = (s) => readJson(s, 'installs.json', {});
|
|
18
|
+
export const saveInstalls = (s, installs) => s.set('installs.json', JSON.stringify(installs));
|
|
19
|
+
export const getCatalog = (s, sourceId) => readJson(s, `catalogs/${sourceId}.json`, null);
|
|
20
|
+
export const saveCatalog = (s, catalog) => s.set(`catalogs/${catalog.sourceId}.json`, JSON.stringify(catalog));
|
|
21
|
+
export const deleteCatalog = (s, sourceId) => s.delete(`catalogs/${sourceId}.json`);
|
|
22
|
+
export const fileKey = (pluginId, kind, sha256) => `files/${pluginId}/${sha256}.${FILE_NAMES[kind]}`;
|
|
23
|
+
export async function putFile(s, pluginId, kind, sha256, content) {
|
|
24
|
+
const key = fileKey(pluginId, kind, sha256);
|
|
25
|
+
await s.set(key, content);
|
|
26
|
+
globals().files.set(key, content);
|
|
27
|
+
}
|
|
28
|
+
/** Plugin files are content-addressed and never change, so they are cached in memory for the life of the process. */
|
|
29
|
+
export async function getFile(s, pluginId, kind, sha256) {
|
|
30
|
+
const key = fileKey(pluginId, kind, sha256);
|
|
31
|
+
const cache = globals().files;
|
|
32
|
+
const hit = cache.get(key);
|
|
33
|
+
if (hit !== undefined)
|
|
34
|
+
return hit;
|
|
35
|
+
const content = await s.get(key);
|
|
36
|
+
if (content !== null)
|
|
37
|
+
cache.set(key, content);
|
|
38
|
+
return content;
|
|
39
|
+
}
|
|
40
|
+
/** Delete stored files of a plugin except the ones listed. */
|
|
41
|
+
export async function pruneFiles(s, pluginId, keep) {
|
|
42
|
+
const keepKeys = new Set();
|
|
43
|
+
for (const set of keep)
|
|
44
|
+
for (const [kind, f] of Object.entries(set))
|
|
45
|
+
if (f)
|
|
46
|
+
keepKeys.add(fileKey(pluginId, kind, f.sha256));
|
|
47
|
+
for (const key of await s.list(`files/${pluginId}/`)) {
|
|
48
|
+
if (keepKeys.has(key))
|
|
49
|
+
continue;
|
|
50
|
+
await s.delete(key);
|
|
51
|
+
globals().files.delete(key);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
export const dataKey = (pluginId, key) => `data/${pluginId}/${encodeSegment(key)}`;
|
|
55
|
+
export async function deletePluginData(s, pluginId) {
|
|
56
|
+
for (const key of await s.list(`data/${pluginId}/`))
|
|
57
|
+
await s.delete(key);
|
|
58
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type { PluginBuild, PluginManifest } from '@nextpluggable/plugin/manifest';
|
|
2
|
+
export interface SourceRecord {
|
|
3
|
+
id: string;
|
|
4
|
+
type: 'github' | 'local';
|
|
5
|
+
/** Human name, e.g. `acme/site-plugins`. */
|
|
6
|
+
label: string;
|
|
7
|
+
addedAt: string;
|
|
8
|
+
github?: {
|
|
9
|
+
owner: string;
|
|
10
|
+
repo: string;
|
|
11
|
+
/** Branch or tag to follow. Default: the repository's default branch. */
|
|
12
|
+
ref?: string;
|
|
13
|
+
/** Sub-directory that holds the plugins. */
|
|
14
|
+
dir?: string;
|
|
15
|
+
/** Access token, encrypted with the site secret. */
|
|
16
|
+
token?: string;
|
|
17
|
+
};
|
|
18
|
+
local?: {
|
|
19
|
+
dir: string;
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
export interface CatalogEntry {
|
|
23
|
+
/** Plugin directory inside the source. */
|
|
24
|
+
path: string;
|
|
25
|
+
build: PluginBuild;
|
|
26
|
+
/** Fingerprint of dist/build.json — changes whenever the plugin is rebuilt. */
|
|
27
|
+
buildHash: string;
|
|
28
|
+
}
|
|
29
|
+
export interface Catalog {
|
|
30
|
+
sourceId: string;
|
|
31
|
+
/** Commit the listing was read at. */
|
|
32
|
+
revision: string;
|
|
33
|
+
fetchedAt: string;
|
|
34
|
+
name?: string;
|
|
35
|
+
description?: string;
|
|
36
|
+
plugins: CatalogEntry[];
|
|
37
|
+
problems: {
|
|
38
|
+
path: string;
|
|
39
|
+
message: string;
|
|
40
|
+
}[];
|
|
41
|
+
}
|
|
42
|
+
export type FileKind = 'client' | 'server' | 'style';
|
|
43
|
+
export interface StoredFile {
|
|
44
|
+
sha256: string;
|
|
45
|
+
bytes: number;
|
|
46
|
+
}
|
|
47
|
+
export interface InstallRecord {
|
|
48
|
+
id: string;
|
|
49
|
+
manifest: PluginManifest;
|
|
50
|
+
icon?: string;
|
|
51
|
+
buildHash: string;
|
|
52
|
+
source: {
|
|
53
|
+
id: string;
|
|
54
|
+
label: string;
|
|
55
|
+
path: string;
|
|
56
|
+
revision: string;
|
|
57
|
+
};
|
|
58
|
+
files: Partial<Record<FileKind, StoredFile>>;
|
|
59
|
+
/** Files of the previously installed version, kept so pages cached before an update keep working. */
|
|
60
|
+
previousFiles?: Partial<Record<FileKind, StoredFile>>;
|
|
61
|
+
enabled: boolean;
|
|
62
|
+
/** Setting values; secrets are encrypted. */
|
|
63
|
+
config: Record<string, string | number | boolean>;
|
|
64
|
+
/** Page addresses chosen by the site owner, keyed by page id. */
|
|
65
|
+
paths: Record<string, string>;
|
|
66
|
+
installedAt: string;
|
|
67
|
+
updatedAt: string;
|
|
68
|
+
}
|
|
69
|
+
export type Installs = Record<string, InstallRecord>;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/crypto.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export declare const sha256Hex: (data: string | Uint8Array) => string;
|
|
2
|
+
export declare function hmac(secret: string, data: string): string;
|
|
3
|
+
/** Constant-time string comparison (compares digests so length is not leaked). */
|
|
4
|
+
export declare function safeEqual(a: string, b: string): boolean;
|
|
5
|
+
export declare const isEncrypted: (value: unknown) => value is string;
|
|
6
|
+
/** AES-256-GCM with a key derived from the site secret. Used for plugin secrets and source tokens at rest. */
|
|
7
|
+
export declare function encrypt(secret: string, plaintext: string): string;
|
|
8
|
+
/** Returns null when the value cannot be decrypted (wrong secret or tampering). */
|
|
9
|
+
export declare function decrypt(secret: string, value: string): string | null;
|
|
10
|
+
/** A signed, expiring token: `<payload>.<signature>`. */
|
|
11
|
+
export declare function signToken(secret: string, payload: Record<string, unknown>): string;
|
|
12
|
+
export declare function verifyToken<T extends {
|
|
13
|
+
exp: number;
|
|
14
|
+
}>(secret: string, token: string | undefined | null): T | null;
|
package/dist/crypto.js
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { createCipheriv, createDecipheriv, createHash, createHmac, hkdfSync, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
export const sha256Hex = (data) => createHash('sha256').update(data).digest('hex');
|
|
3
|
+
const b64u = (buf) => Buffer.from(buf).toString('base64url');
|
|
4
|
+
export function hmac(secret, data) {
|
|
5
|
+
return createHmac('sha256', secret).update(data).digest('base64url');
|
|
6
|
+
}
|
|
7
|
+
/** Constant-time string comparison (compares digests so length is not leaked). */
|
|
8
|
+
export function safeEqual(a, b) {
|
|
9
|
+
return timingSafeEqual(createHash('sha256').update(a).digest(), createHash('sha256').update(b).digest());
|
|
10
|
+
}
|
|
11
|
+
function key(secret, purpose) {
|
|
12
|
+
return Buffer.from(hkdfSync('sha256', secret, 'nextpluggable', purpose, 32));
|
|
13
|
+
}
|
|
14
|
+
const ENC_PREFIX = 'enc:v1:';
|
|
15
|
+
export const isEncrypted = (value) => typeof value === 'string' && value.startsWith(ENC_PREFIX);
|
|
16
|
+
/** AES-256-GCM with a key derived from the site secret. Used for plugin secrets and source tokens at rest. */
|
|
17
|
+
export function encrypt(secret, plaintext) {
|
|
18
|
+
const iv = randomBytes(12);
|
|
19
|
+
const cipher = createCipheriv('aes-256-gcm', key(secret, 'settings'), iv);
|
|
20
|
+
const body = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final(), cipher.getAuthTag()]);
|
|
21
|
+
return `${ENC_PREFIX}${b64u(iv)}:${b64u(body)}`;
|
|
22
|
+
}
|
|
23
|
+
/** Returns null when the value cannot be decrypted (wrong secret or tampering). */
|
|
24
|
+
export function decrypt(secret, value) {
|
|
25
|
+
if (!isEncrypted(value))
|
|
26
|
+
return null;
|
|
27
|
+
const [ivPart, bodyPart] = value.slice(ENC_PREFIX.length).split(':');
|
|
28
|
+
if (!ivPart || !bodyPart)
|
|
29
|
+
return null;
|
|
30
|
+
try {
|
|
31
|
+
const body = Buffer.from(bodyPart, 'base64url');
|
|
32
|
+
const decipher = createDecipheriv('aes-256-gcm', key(secret, 'settings'), Buffer.from(ivPart, 'base64url'));
|
|
33
|
+
decipher.setAuthTag(body.subarray(body.length - 16));
|
|
34
|
+
return Buffer.concat([decipher.update(body.subarray(0, body.length - 16)), decipher.final()]).toString('utf8');
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/** A signed, expiring token: `<payload>.<signature>`. */
|
|
41
|
+
export function signToken(secret, payload) {
|
|
42
|
+
const body = Buffer.from(JSON.stringify(payload)).toString('base64url');
|
|
43
|
+
return `${body}.${hmac(secret, body)}`;
|
|
44
|
+
}
|
|
45
|
+
export function verifyToken(secret, token) {
|
|
46
|
+
if (!token)
|
|
47
|
+
return null;
|
|
48
|
+
const dot = token.lastIndexOf('.');
|
|
49
|
+
if (dot < 1)
|
|
50
|
+
return null;
|
|
51
|
+
const body = token.slice(0, dot);
|
|
52
|
+
if (!safeEqual(hmac(secret, body), token.slice(dot + 1)))
|
|
53
|
+
return null;
|
|
54
|
+
try {
|
|
55
|
+
const payload = JSON.parse(Buffer.from(body, 'base64url').toString('utf8'));
|
|
56
|
+
return typeof payload.exp === 'number' && payload.exp > Date.now() ? payload : null;
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
import type { SessionView } from '../views.js';
|
|
3
|
+
export interface DashboardProps {
|
|
4
|
+
apiPath: string;
|
|
5
|
+
dashboardPath: string;
|
|
6
|
+
siteName: string;
|
|
7
|
+
session: SessionView;
|
|
8
|
+
/** Set when the site's configuration is incomplete (shown instead of the dashboard). */
|
|
9
|
+
setupError?: string;
|
|
10
|
+
}
|
|
11
|
+
export declare function Dashboard(props: DashboardProps): React.JSX.Element;
|