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
package/README.md
CHANGED
|
@@ -1,3 +1,289 @@
|
|
|
1
|
-
#
|
|
1
|
+
# NextPluggable
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
WordPress-style plugins for Next.js. Add one package to a site and its owner gets a
|
|
4
|
+
dashboard where they connect GitHub repositories that hold plugins, then install,
|
|
5
|
+
configure, update and remove those plugins themselves: no developer, no rebuild, no
|
|
6
|
+
redeploy.
|
|
7
|
+
|
|
8
|
+
Plugins are full features, not UI snippets. One plugin can add server-rendered pages
|
|
9
|
+
that search engines index, HTTP endpoints, stored data, widgets in the site's layout,
|
|
10
|
+
a settings form, and its own screens inside the dashboard.
|
|
11
|
+
|
|
12
|
+
> **Status: working prototype.** Everything described here is
|
|
13
|
+
> implemented and covered by tests in this repository. See [Limits](#limits) before
|
|
14
|
+
> relying on it, and [docs/research.md](https://github.com/rohittp0/NextPluggable/blob/main/docs/research.md) for why it is built this way.
|
|
15
|
+
|
|
16
|
+
**Documentation: <https://rohittp0.github.io/NextPluggable/>.** Each guide also exists
|
|
17
|
+
as Markdown written for coding agents: give yours
|
|
18
|
+
[`integrate.md`](https://rohittp0.github.io/NextPluggable/integrate.md) to add
|
|
19
|
+
NextPluggable to a site, or
|
|
20
|
+
[`build.md`](https://rohittp0.github.io/NextPluggable/build.md) to write plugins.
|
|
21
|
+
|
|
22
|
+
## What the site owner does
|
|
23
|
+
|
|
24
|
+
1. Opens `/admin/plugins` and signs in.
|
|
25
|
+
2. Pastes the address of a GitHub repository under **Sources**. For a private
|
|
26
|
+
repository, the dashboard walks them through creating a read-only access token.
|
|
27
|
+
3. Picks a plugin under **Add plugins**. A confirmation lists, in plain language, what
|
|
28
|
+
it adds and what it is allowed to do.
|
|
29
|
+
4. The plugin is live about a second later. Its settings are an ordinary form; the
|
|
30
|
+
address of each page it adds can be changed; it can be paused, updated or removed
|
|
31
|
+
with one click.
|
|
32
|
+
|
|
33
|
+
## What the developer does, once
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
npm install nextpluggable
|
|
37
|
+
npx nextpluggable init
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
`init` adds four small files to an App Router project and never overwrites existing
|
|
41
|
+
ones:
|
|
42
|
+
|
|
43
|
+
| File | Purpose |
|
|
44
|
+
|---|---|
|
|
45
|
+
| `lib/nextpluggable.ts` | Settings: sign-in, storage, site name |
|
|
46
|
+
| `app/admin/plugins/[[...np]]/page.tsx` | The dashboard |
|
|
47
|
+
| `app/api/nextpluggable/[...np]/route.ts` | The dashboard's API, plugin files, and plugins' own endpoints |
|
|
48
|
+
| `app/[...slug]/page.tsx` | Serves the pages plugins add. Your own routes always win; unknown addresses still get your 404 page. Optional page caching is two commented lines in this file |
|
|
49
|
+
|
|
50
|
+
It also writes a generated dashboard password and secret to `.env.local`.
|
|
51
|
+
|
|
52
|
+
If the site already has a root catch-all route, `init` leaves it alone. Serve plugin
|
|
53
|
+
pages from it yourself: render `<np.PluginPage params={params} />` where your own lookup
|
|
54
|
+
finds nothing, or, when that route never returns a 404 (a landing page for any address,
|
|
55
|
+
say), ask first:
|
|
56
|
+
|
|
57
|
+
```tsx
|
|
58
|
+
if (await np.hasPluginPage(`/${segments.join('/')}`)) return <np.PluginPage params={params} />;
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Optionally, mark places in your layout that plugins may fill:
|
|
62
|
+
|
|
63
|
+
```tsx
|
|
64
|
+
import { np } from '@/lib/nextpluggable';
|
|
65
|
+
|
|
66
|
+
<nav>
|
|
67
|
+
…
|
|
68
|
+
<np.Slot name="header.actions" />
|
|
69
|
+
</nav>
|
|
70
|
+
…
|
|
71
|
+
<np.Slot name="body.end" />
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
### Settings
|
|
75
|
+
|
|
76
|
+
```ts
|
|
77
|
+
import { createNextPluggable, customAuth, upstashStorage } from 'nextpluggable';
|
|
78
|
+
|
|
79
|
+
export const np = createNextPluggable({
|
|
80
|
+
siteName: 'Harbor Lending',
|
|
81
|
+
// Who may use the dashboard. Default: one password from NEXTPLUGGABLE_PASSWORD.
|
|
82
|
+
auth: customAuth({
|
|
83
|
+
getUser: async (request) => {
|
|
84
|
+
const session = await getSessionSomehow(request); // Auth.js, Clerk, your own…
|
|
85
|
+
return session?.role === 'admin' ? { id: session.userId, name: session.name } : null;
|
|
86
|
+
},
|
|
87
|
+
loginUrl: '/login',
|
|
88
|
+
}),
|
|
89
|
+
// Where data lives. Default: a .nextpluggable folder.
|
|
90
|
+
storage: upstashStorage(),
|
|
91
|
+
});
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
| Storage | Use it when |
|
|
95
|
+
|---|---|
|
|
96
|
+
| `fileStorage()` (default) | Local development, or one long-running server with a disk. Set `NEXTPLUGGABLE_DATA_DIR` to choose the folder |
|
|
97
|
+
| `postgresStorage({ query })` | You already have Postgres. Pass your client's query function, e.g. `(text, params) => pool.query(text, params)` |
|
|
98
|
+
| `upstashStorage()` | Serverless hosts such as Vercel. Reads `UPSTASH_REDIS_REST_URL` and `UPSTASH_REDIS_REST_TOKEN` |
|
|
99
|
+
| `memoryStorage()` | Tests |
|
|
100
|
+
|
|
101
|
+
| Environment variable | Meaning |
|
|
102
|
+
|---|---|
|
|
103
|
+
| `NEXTPLUGGABLE_PASSWORD` | Dashboard password (when using the default sign-in) |
|
|
104
|
+
| `NEXTPLUGGABLE_SECRET` | Signs sessions and encrypts stored secrets. Required in production |
|
|
105
|
+
| `NEXT_PUBLIC_SITE_URL` | Public address of the site, used for canonical links |
|
|
106
|
+
| `NEXTPLUGGABLE_GITHUB_TOKEN` | Optional token used for every GitHub source that has none of its own; also lifts GitHub's limit of 60 requests an hour |
|
|
107
|
+
|
|
108
|
+
## Writing plugins
|
|
109
|
+
|
|
110
|
+
A plugin is a folder with a manifest, React components and optional server code:
|
|
111
|
+
|
|
112
|
+
```
|
|
113
|
+
my-plugin/
|
|
114
|
+
plugin.json what it adds, its settings form, what it may do
|
|
115
|
+
src/client.tsx React components: pages, layout widgets, dashboard screens
|
|
116
|
+
src/server.ts page loaders (data + SEO metadata) and HTTP endpoints
|
|
117
|
+
dist/ built output, committed
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
```bash
|
|
121
|
+
npm install --save-dev @nextpluggable/plugin esbuild
|
|
122
|
+
npx nextpluggable-plugin build
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
A repository can hold one plugin at its root, or several listed in a root
|
|
126
|
+
`nextpluggable.json`. Hosts install straight from the repository at an exact commit.
|
|
127
|
+
The full guide is in [docs/build.md](https://github.com/rohittp0/NextPluggable/blob/main/docs/build.md).
|
|
128
|
+
|
|
129
|
+
This repository is itself a plugin source with two reference plugins:
|
|
130
|
+
|
|
131
|
+
| Plugin | Shows |
|
|
132
|
+
|---|---|
|
|
133
|
+
| [EMI Calculator](https://github.com/rohittp0/NextPluggable/tree/main/plugins/emi-calculator) | A standalone tool: one server-rendered, interactive page with a dozen settings. 5 kB of JavaScript |
|
|
134
|
+
| [Enquiry Form](https://github.com/rohittp0/NextPluggable/tree/main/plugins/enquiry-form) | A full-stack feature: a page, a public endpoint with validation, stored data, a floating button on every page, an inbox inside the dashboard, and an encrypted webhook setting |
|
|
135
|
+
|
|
136
|
+
## How it works
|
|
137
|
+
|
|
138
|
+
- **Loading.** A plugin ships prebuilt bundles that leave React to the host. When a
|
|
139
|
+
plugin page is requested, the site server-renders the plugin's component inside the
|
|
140
|
+
page using its own React, then the browser loads the same bundle as a normal script
|
|
141
|
+
and hydrates. Plugin code is never part of the site's build.
|
|
142
|
+
- **SEO.** Plugin pages are real server-rendered HTML, complete for search engines by
|
|
143
|
+
default: their own title, description, canonical link, Open Graph and Twitter tags,
|
|
144
|
+
structured data, and an entry in the sitemap (`np.sitemap()`). A plugin can say more;
|
|
145
|
+
it never has to say anything. Addresses no plugin owns return a real 404.
|
|
146
|
+
- **Speed and cost.** Sharing the site's React keeps plugin bundles small (the EMI
|
|
147
|
+
calculator is 5 kB). A plugin's stylesheet is written into the page and its script
|
|
148
|
+
loads without blocking, so a widget on every page adds no request the browser waits
|
|
149
|
+
for. By default plugin pages are rendered on each request, which is
|
|
150
|
+
always correct. They can instead be cached like static pages and refreshed the moment
|
|
151
|
+
anything changes in the dashboard; see [Caching plugin pages](#caching-plugin-pages).
|
|
152
|
+
- **Safety net.** A plugin that crashes while rendering is contained: a broken widget
|
|
153
|
+
disappears and a broken page shows a short message, while the rest of the site, and
|
|
154
|
+
the dashboard needed to remove it, keep working. A plugin that fails to start is not
|
|
155
|
+
installed, and a failed update leaves the working version in place.
|
|
156
|
+
|
|
157
|
+
## Caching plugin pages
|
|
158
|
+
|
|
159
|
+
Uncommenting two lines in the generated `app/[...slug]/page.tsx` makes Next.js render
|
|
160
|
+
each plugin page once and serve it from its cache until something changes in the
|
|
161
|
+
dashboard:
|
|
162
|
+
|
|
163
|
+
```ts
|
|
164
|
+
export const generateStaticParams = async () => [];
|
|
165
|
+
export const revalidate = 3600;
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
This is the cheapest way to run, and worth doing on sites that are mostly public
|
|
169
|
+
content. Two things to know first, both measured on Next.js 16.4:
|
|
170
|
+
|
|
171
|
+
- **Your root layout must not read `cookies()` or `headers()` on the server.** If it
|
|
172
|
+
does, cached plugin pages and unknown addresses fail with a 500 error in production
|
|
173
|
+
builds. It does not show up in `next dev`, so check with `next build && next start`.
|
|
174
|
+
- **Next.js stores the address of the request that first generated a cached page,
|
|
175
|
+
query string included, in that page's inline data**, and serves it to later
|
|
176
|
+
visitors. This is how Next.js caches any on-demand page, not something specific to
|
|
177
|
+
plugins. If links to your site can carry private values in the query string, leave
|
|
178
|
+
caching off.
|
|
179
|
+
|
|
180
|
+
Do not use `export const dynamic = 'force-static'` for this route: with it, a
|
|
181
|
+
component that reads the query string has the first visitor's value rendered into the
|
|
182
|
+
page everyone receives.
|
|
183
|
+
|
|
184
|
+
Whatever the mode, plugin components never render the query string on the server: the
|
|
185
|
+
SDK only makes it available in the browser.
|
|
186
|
+
|
|
187
|
+
## Security
|
|
188
|
+
|
|
189
|
+
- **Plugins are trusted code.** A plugin's server code runs inside your site and can do
|
|
190
|
+
anything your site can, exactly like a WordPress plugin or an npm package. There is
|
|
191
|
+
no sandbox yet. Connect only sources you trust.
|
|
192
|
+
- What protects you today: only sources the owner connects are used; every install is
|
|
193
|
+
pinned to an exact commit; every file is checked against the fingerprint recorded
|
|
194
|
+
when the plugin was built; the install dialog states what the plugin does.
|
|
195
|
+
- The dashboard requires a sign-in for everything. Sessions are signed, http-only
|
|
196
|
+
cookies. Writes are protected against cross-site request forgery. Repeated wrong
|
|
197
|
+
passwords are slowed down.
|
|
198
|
+
- Access tokens and secret settings are encrypted at rest and never sent back to the
|
|
199
|
+
browser. Settings marked server-only never reach public pages.
|
|
200
|
+
- Folder-based sources (for plugin development) are off in production.
|
|
201
|
+
|
|
202
|
+
## Limits
|
|
203
|
+
|
|
204
|
+
- **Next.js Cache Components mode is not supported yet.** With `cacheComponents: true`
|
|
205
|
+
the build fails. This is the first item on the roadmap because Next.js plans to make
|
|
206
|
+
that mode the default in version 17.
|
|
207
|
+
- **Node.js runtime only**, 20.16 or newer. It does not run on Cloudflare Workers or
|
|
208
|
+
other runtimes that forbid evaluating code.
|
|
209
|
+
- **Serverless hosts need a database adapter**; the default file storage needs a disk.
|
|
210
|
+
- **Several server instances:** a dashboard change reaches the other instances'
|
|
211
|
+
in-memory plugin list within about five seconds, and cached pages rely on your host
|
|
212
|
+
propagating Next.js revalidation. This has been tested on a single instance only.
|
|
213
|
+
- **Do not put a `loading.tsx` above the catch-all route** while it is uncached (the
|
|
214
|
+
default). Next.js would start streaming before the plugin lookup finishes, and
|
|
215
|
+
unknown addresses would return 200 with `noindex` instead of a 404. With caching
|
|
216
|
+
switched on they return a real 404 either way.
|
|
217
|
+
- **Plugins cannot read the query string on the server.** Pages that vary for search
|
|
218
|
+
engines should use address segments (`/shop/page/2`); filters and searches read the
|
|
219
|
+
query in the browser.
|
|
220
|
+
- **Plugin UI is React**, and plugins cannot use Next.js-only features such as server
|
|
221
|
+
components or server actions.
|
|
222
|
+
- **Plugin storage is a simple key-value store**: fine for settings, messages and small
|
|
223
|
+
catalogues, not yet for large or heavily queried data.
|
|
224
|
+
|
|
225
|
+
## Roadmap
|
|
226
|
+
|
|
227
|
+
1. Cache Components support.
|
|
228
|
+
2. One-click GitHub connection for private repositories (a GitHub App created from the
|
|
229
|
+
dashboard) in place of pasted tokens.
|
|
230
|
+
3. Sandboxed execution of plugin server code, with the permissions list enforced.
|
|
231
|
+
4. A shopping cart reference plugin, and richer storage (collections with queries).
|
|
232
|
+
5. A second host adapter (Astro) on the same plugin format.
|
|
233
|
+
|
|
234
|
+
## Repository
|
|
235
|
+
|
|
236
|
+
| Path | Contents |
|
|
237
|
+
|---|---|
|
|
238
|
+
| `packages/nextpluggable` | The package sites install: dashboard, sign-in, storage, GitHub sources, installer, runtime |
|
|
239
|
+
| `packages/plugin` | `@nextpluggable/plugin`: the SDK and build tool for plugin authors |
|
|
240
|
+
| `plugins/` | Reference plugins, with their built `dist/` committed |
|
|
241
|
+
| `e2e/` | Browser test of the whole owner journey against a mock GitHub API, and the small Next.js site (`e2e/site`) it runs against |
|
|
242
|
+
| `docs/` | The documentation site, served by GitHub Pages: the guides ([integrate](https://github.com/rohittp0/NextPluggable/blob/main/docs/integrate.md), [build plugins](https://github.com/rohittp0/NextPluggable/blob/main/docs/build.md)) and [research and decisions](https://github.com/rohittp0/NextPluggable/blob/main/docs/research.md) |
|
|
243
|
+
| `scripts/` | The release version script and the documentation site generator |
|
|
244
|
+
|
|
245
|
+
### Checks
|
|
246
|
+
|
|
247
|
+
```bash
|
|
248
|
+
pnpm build # packages, then plugins
|
|
249
|
+
pnpm typecheck
|
|
250
|
+
pnpm test # unit tests; set TEST_POSTGRES_URL to include the Postgres adapter
|
|
251
|
+
pnpm e2e # browser test, run twice (pages uncached, then cached); needs Playwright with Chromium
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
Rebuilding the plugins must leave `plugins/*/dist` unchanged: builds are reproducible,
|
|
255
|
+
and the committed output is what sites install.
|
|
256
|
+
|
|
257
|
+
### Documentation site
|
|
258
|
+
|
|
259
|
+
GitHub Pages serves the `docs/` folder as it is. The guides are written once, as
|
|
260
|
+
`docs/integrate.md` and `docs/build.md`: those files are what coding agents read, and
|
|
261
|
+
`pnpm docs:build` renders them to the HTML pages people read. Commit the generated
|
|
262
|
+
pages with the Markdown change; `pnpm test` fails when they are out of date. Changes
|
|
263
|
+
under `docs/` do not publish a release.
|
|
264
|
+
|
|
265
|
+
### Releases
|
|
266
|
+
|
|
267
|
+
Every merge to `main` publishes `nextpluggable` and `@nextpluggable/plugin` to npm
|
|
268
|
+
under one shared version ([`release.yml`](.github/workflows/release.yml)), tags the
|
|
269
|
+
commit and creates a GitHub release. The patch number goes up by one each time. For a
|
|
270
|
+
minor or major release, set the new version in both `packages/*/package.json` files in
|
|
271
|
+
the pull request; it is published as it is, and patch releases continue from there.
|
|
272
|
+
|
|
273
|
+
No npm token is stored in the repository. npm lets the workflow publish because each
|
|
274
|
+
package names it as its [trusted publisher](https://docs.npmjs.com/trusted-publishers),
|
|
275
|
+
which can only be set up once a package exists. So there is a one-time setup:
|
|
276
|
+
|
|
277
|
+
1. Publish the first version by hand, signed in to npm as yourself:
|
|
278
|
+
|
|
279
|
+
```bash
|
|
280
|
+
pnpm install && pnpm build
|
|
281
|
+
(cd packages/plugin && pnpm publish --access public --no-git-checks)
|
|
282
|
+
(cd packages/nextpluggable && pnpm publish --access public --no-git-checks)
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
2. On npmjs.com, open each package's settings and add a trusted publisher: GitHub
|
|
286
|
+
Actions, user `rohittp0`, repository `NextPluggable`, workflow `release.yml`, with
|
|
287
|
+
`npm publish` allowed.
|
|
288
|
+
3. Merge to `main` within two days. npm drops a trusted publisher that has not
|
|
289
|
+
published by then.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { ResolvedConfig } from '../config.js';
|
|
2
|
+
import { type SessionView } from '../views.js';
|
|
3
|
+
export interface HandlerHooks {
|
|
4
|
+
/** Called after anything that changes what the public site renders. */
|
|
5
|
+
onSiteChange?: () => void | Promise<void>;
|
|
6
|
+
}
|
|
7
|
+
/** Header every dashboard/plugin write must carry. Browsers will not send it cross-site without a CORS preflight, which we never grant. */
|
|
8
|
+
export declare const CSRF_HEADER = "x-nextpluggable";
|
|
9
|
+
export declare function sessionView(cfg: ResolvedConfig, request: Request): Promise<SessionView>;
|
|
10
|
+
export declare function createHandler(getConfig: () => ResolvedConfig, hooks?: HandlerHooks): (request: Request) => Promise<Response>;
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { addSource, changePlugin, installPlugin, refreshAllSources, removeSource, uninstallPlugin, updatePlugin } from '../core/installer.js';
|
|
2
|
+
import { handlePluginApi } from '../core/runtime.js';
|
|
3
|
+
import { getInstalledPlugins } from '../core/state.js';
|
|
4
|
+
import { getFile, getInstalls } from '../core/store.js';
|
|
5
|
+
import { HttpError, jsonResponse } from '../errors.js';
|
|
6
|
+
import { warnOnce } from '../globals.js';
|
|
7
|
+
import { buildDashboardState } from '../views.js';
|
|
8
|
+
const MUTATING = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
|
|
9
|
+
/** Header every dashboard/plugin write must carry. Browsers will not send it cross-site without a CORS preflight, which we never grant. */
|
|
10
|
+
export const CSRF_HEADER = 'x-nextpluggable';
|
|
11
|
+
const hostOf = (value) => {
|
|
12
|
+
if (!value)
|
|
13
|
+
return null;
|
|
14
|
+
try {
|
|
15
|
+
return new URL(value.includes('://') ? value : `http://${value}`).host.toLowerCase();
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
/**
|
|
22
|
+
* A browser states where a write came from in its Origin header. It must be this site: the host
|
|
23
|
+
* the request was addressed to, the host a proxy says it forwarded, or the site's configured
|
|
24
|
+
* public address (for proxies that rewrite the host and forward nothing).
|
|
25
|
+
*/
|
|
26
|
+
function sameOrigin(request, cfg) {
|
|
27
|
+
const origin = request.headers.get('origin');
|
|
28
|
+
if (!origin)
|
|
29
|
+
return true; // non-browser clients; the custom header check still applies
|
|
30
|
+
const from = hostOf(origin);
|
|
31
|
+
if (!from)
|
|
32
|
+
return false;
|
|
33
|
+
const here = [request.headers.get('x-forwarded-host')?.split(',')[0]?.trim(), request.headers.get('host'), new URL(request.url).host, cfg.siteUrl].map(hostOf);
|
|
34
|
+
if (here.includes(from))
|
|
35
|
+
return true;
|
|
36
|
+
warnOnce(`origin:${from}`, `Blocked a write from ${origin}: it does not match this site (addressed to ${request.headers.get('host') ?? 'unknown'}, forwarded host ${request.headers.get('x-forwarded-host') ?? 'none'}, configured address ${cfg.siteUrl}). If the site sits behind a proxy, set NEXT_PUBLIC_SITE_URL (or siteUrl) to its public address.`);
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
/** Look a plugin up by an id that came from a URL, ignoring inherited object properties. */
|
|
40
|
+
const own = (map, key) => (Object.hasOwn(map, key) ? map[key] : undefined);
|
|
41
|
+
const csrfOk = (request, cfg) => request.headers.get(CSRF_HEADER) === '1' && sameOrigin(request, cfg);
|
|
42
|
+
async function readBody(request) {
|
|
43
|
+
const text = await request.text();
|
|
44
|
+
if (text.length > 200_000)
|
|
45
|
+
throw new HttpError(413, 'That request is too large.');
|
|
46
|
+
if (!text)
|
|
47
|
+
return {};
|
|
48
|
+
try {
|
|
49
|
+
const body = JSON.parse(text);
|
|
50
|
+
return typeof body === 'object' && body !== null && !Array.isArray(body) ? body : {};
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
throw new HttpError(400, 'Invalid request.');
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
export async function sessionView(cfg, request) {
|
|
57
|
+
let user = null;
|
|
58
|
+
try {
|
|
59
|
+
user = await cfg.auth.getUser(request, { secret: cfg.secret });
|
|
60
|
+
}
|
|
61
|
+
catch (e) {
|
|
62
|
+
console.error('[nextpluggable] auth.getUser failed:', e);
|
|
63
|
+
}
|
|
64
|
+
return { user, mode: cfg.auth.kind, configured: cfg.auth.configured, loginUrl: cfg.auth.loginUrl };
|
|
65
|
+
}
|
|
66
|
+
const ASSET_TYPES = {
|
|
67
|
+
'client.js': { kind: 'client', type: 'text/javascript; charset=utf-8' },
|
|
68
|
+
'style.css': { kind: 'style', type: 'text/css; charset=utf-8' },
|
|
69
|
+
};
|
|
70
|
+
async function serveAsset(cfg, pluginId, hash, name) {
|
|
71
|
+
const asset = ASSET_TYPES[name];
|
|
72
|
+
if (!asset || !/^[0-9a-f]{16}$/.test(hash))
|
|
73
|
+
return jsonResponse({ error: 'Not found.' }, 404);
|
|
74
|
+
const install = own(await getInstalledPlugins(cfg), pluginId) ?? own(await getInstalls(cfg.storage), pluginId);
|
|
75
|
+
// The previous version's files stay available so pages cached before an update still load.
|
|
76
|
+
const file = [install?.files[asset.kind], install?.previousFiles?.[asset.kind]].find((f) => f?.sha256.startsWith(hash));
|
|
77
|
+
const content = file ? await getFile(cfg.storage, pluginId, asset.kind, file.sha256) : null;
|
|
78
|
+
if (content === null)
|
|
79
|
+
return jsonResponse({ error: 'Not found.' }, 404);
|
|
80
|
+
const body = asset.kind === 'client'
|
|
81
|
+
? // One artifact, two consumers: the server evaluates the module body directly; browsers get it
|
|
82
|
+
// wrapped in a registration call, so no eval (and no `unsafe-eval` CSP) is needed client-side.
|
|
83
|
+
`(self.__NP_Q__=self.__NP_Q__||[]).push([${JSON.stringify(`${pluginId}@${hash}`)},function(require,module,exports){${content}\n}]);`
|
|
84
|
+
: content;
|
|
85
|
+
return new Response(body, {
|
|
86
|
+
headers: {
|
|
87
|
+
'content-type': asset.type,
|
|
88
|
+
'cache-control': 'public, max-age=31536000, s-maxage=31536000, immutable',
|
|
89
|
+
'x-content-type-options': 'nosniff',
|
|
90
|
+
},
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
export function createHandler(getConfig, hooks = {}) {
|
|
94
|
+
async function route(request) {
|
|
95
|
+
const cfg = getConfig();
|
|
96
|
+
const pathname = new URL(request.url).pathname;
|
|
97
|
+
const at = pathname.indexOf(cfg.apiPath);
|
|
98
|
+
const sub = at === -1 ? pathname : pathname.slice(at + cfg.apiPath.length);
|
|
99
|
+
const seg = sub.split('/').filter(Boolean).map((s) => decodeURIComponent(s));
|
|
100
|
+
const method = request.method.toUpperCase();
|
|
101
|
+
const env = { secret: cfg.secret };
|
|
102
|
+
// ---- public: plugin assets
|
|
103
|
+
if (seg[0] === 'assets' && seg.length === 4 && (method === 'GET' || method === 'HEAD'))
|
|
104
|
+
return serveAsset(cfg, seg[1], seg[2], seg[3]);
|
|
105
|
+
// ---- public: plugin APIs
|
|
106
|
+
if (seg[0] === 'p' && seg[1]) {
|
|
107
|
+
const install = own(await getInstalledPlugins(cfg), seg[1]);
|
|
108
|
+
if (!install || !install.enabled)
|
|
109
|
+
return jsonResponse({ error: 'Not found.' }, 404);
|
|
110
|
+
// A plugin only sees the dashboard user when the request provably came from this site's own pages.
|
|
111
|
+
const admin = !MUTATING.has(method) || csrfOk(request, cfg) ? await cfg.auth.getUser(request, env).catch(() => null) : null;
|
|
112
|
+
const res = await handlePluginApi(cfg, install, request, `/${seg.slice(2).join('/')}`, admin);
|
|
113
|
+
if (MUTATING.has(method) && admin)
|
|
114
|
+
await hooks.onSiteChange?.();
|
|
115
|
+
return res;
|
|
116
|
+
}
|
|
117
|
+
// ---- session
|
|
118
|
+
if (seg[0] === 'session' && method === 'GET')
|
|
119
|
+
return jsonResponse(await sessionView(cfg, request));
|
|
120
|
+
if (MUTATING.has(method) && !csrfOk(request, cfg))
|
|
121
|
+
throw new HttpError(403, 'This request was blocked for security reasons. Reload the page and try again.');
|
|
122
|
+
if (seg[0] === 'login' && method === 'POST') {
|
|
123
|
+
if (!cfg.auth.login)
|
|
124
|
+
throw new HttpError(404, 'This site signs in a different way.');
|
|
125
|
+
return cfg.auth.login(request, env);
|
|
126
|
+
}
|
|
127
|
+
if (seg[0] === 'logout' && method === 'POST')
|
|
128
|
+
return cfg.auth.logout ? cfg.auth.logout(request, env) : jsonResponse({ ok: true });
|
|
129
|
+
// ---- dashboard (signed-in only)
|
|
130
|
+
if (seg[0] !== 'admin')
|
|
131
|
+
throw new HttpError(404, 'Not found.');
|
|
132
|
+
const user = await cfg.auth.getUser(request, env);
|
|
133
|
+
if (!user)
|
|
134
|
+
throw new HttpError(401, 'Please sign in again.');
|
|
135
|
+
const state = async (extra) => jsonResponse({ ...(await buildDashboardState(cfg)), ...extra });
|
|
136
|
+
const changed = async (extra) => {
|
|
137
|
+
await hooks.onSiteChange?.();
|
|
138
|
+
return state(extra);
|
|
139
|
+
};
|
|
140
|
+
if (seg[1] === 'state' && method === 'GET')
|
|
141
|
+
return state();
|
|
142
|
+
if (seg[1] === 'refresh' && method === 'POST')
|
|
143
|
+
return state({ failures: await refreshAllSources(cfg) });
|
|
144
|
+
if (seg[1] === 'sources') {
|
|
145
|
+
if (method === 'POST' && seg.length === 2) {
|
|
146
|
+
const source = await addSource(cfg, await readBody(request));
|
|
147
|
+
return state({ added: source.id });
|
|
148
|
+
}
|
|
149
|
+
if (method === 'DELETE' && seg[2]) {
|
|
150
|
+
await removeSource(cfg, seg[2]);
|
|
151
|
+
return state();
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
if (seg[1] === 'install' && method === 'POST') {
|
|
155
|
+
const record = await installPlugin(cfg, await readBody(request));
|
|
156
|
+
return changed({ installed: record.id });
|
|
157
|
+
}
|
|
158
|
+
if (seg[1] === 'plugins' && seg[2]) {
|
|
159
|
+
const id = seg[2];
|
|
160
|
+
if (seg[3] === 'update' && method === 'POST') {
|
|
161
|
+
await updatePlugin(cfg, id);
|
|
162
|
+
return changed();
|
|
163
|
+
}
|
|
164
|
+
if (seg.length === 3 && method === 'PATCH') {
|
|
165
|
+
await changePlugin(cfg, id, await readBody(request));
|
|
166
|
+
return changed();
|
|
167
|
+
}
|
|
168
|
+
if (seg.length === 3 && method === 'DELETE') {
|
|
169
|
+
const body = await readBody(request);
|
|
170
|
+
await uninstallPlugin(cfg, id, { deleteData: body.deleteData === true });
|
|
171
|
+
return changed();
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
throw new HttpError(404, 'Not found.');
|
|
175
|
+
}
|
|
176
|
+
return async function handle(request) {
|
|
177
|
+
try {
|
|
178
|
+
return await route(request);
|
|
179
|
+
}
|
|
180
|
+
catch (e) {
|
|
181
|
+
if (e instanceof HttpError)
|
|
182
|
+
return jsonResponse({ error: e.message, ...(e.fields ? { fields: e.fields } : {}) }, e.status);
|
|
183
|
+
console.error('[nextpluggable] Unexpected error:', e);
|
|
184
|
+
const message = e instanceof Error && e.message.startsWith('[nextpluggable]') ? e.message.replace('[nextpluggable] ', '') : 'Something went wrong on the server. Please try again.';
|
|
185
|
+
return jsonResponse({ error: message }, 500);
|
|
186
|
+
}
|
|
187
|
+
};
|
|
188
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { AdminUser, AuthAdapter } from './types.js';
|
|
2
|
+
export interface CustomAuthOptions {
|
|
3
|
+
/**
|
|
4
|
+
* Return the signed-in administrator for this request, or null. Called for the
|
|
5
|
+
* dashboard page and for every dashboard API request, with the visitor's
|
|
6
|
+
* cookies and headers on `request`.
|
|
7
|
+
*/
|
|
8
|
+
getUser: (request: Request) => Promise<AdminUser | null> | AdminUser | null;
|
|
9
|
+
/** Where to send people who are not signed in, e.g. `/login`. */
|
|
10
|
+
loginUrl?: string;
|
|
11
|
+
}
|
|
12
|
+
/** Use the site's existing login system (Auth.js, Clerk, Supabase, your own) to protect the dashboard. */
|
|
13
|
+
export declare function customAuth(options: CustomAuthOptions): AuthAdapter;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** Use the site's existing login system (Auth.js, Clerk, Supabase, your own) to protect the dashboard. */
|
|
2
|
+
export function customAuth(options) {
|
|
3
|
+
return {
|
|
4
|
+
kind: 'custom',
|
|
5
|
+
configured: true,
|
|
6
|
+
loginUrl: options.loginUrl,
|
|
7
|
+
async getUser(request) {
|
|
8
|
+
const user = await options.getUser(request);
|
|
9
|
+
return user && typeof user.id === 'string' ? { id: user.id, name: user.name } : null;
|
|
10
|
+
},
|
|
11
|
+
};
|
|
12
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { AuthAdapter } from './types.js';
|
|
2
|
+
export interface PasswordAuthOptions {
|
|
3
|
+
/** The dashboard password. Default: the `NEXTPLUGGABLE_PASSWORD` environment variable. */
|
|
4
|
+
password?: string;
|
|
5
|
+
/** How long a sign-in lasts. Default 7 days. */
|
|
6
|
+
sessionDays?: number;
|
|
7
|
+
cookieName?: string;
|
|
8
|
+
}
|
|
9
|
+
/** One shared password for the dashboard, with a signed, http-only session cookie. */
|
|
10
|
+
export declare function passwordAuth(options?: PasswordAuthOptions): AuthAdapter;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { hmac, safeEqual, signToken, verifyToken } from '../crypto.js';
|
|
2
|
+
import { jsonResponse } from '../errors.js';
|
|
3
|
+
import { globals } from '../globals.js';
|
|
4
|
+
const WINDOW_MS = 10 * 60_000;
|
|
5
|
+
const MAX_ATTEMPTS = 8;
|
|
6
|
+
function clientAddress(request) {
|
|
7
|
+
return request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() || request.headers.get('x-real-ip') || 'unknown';
|
|
8
|
+
}
|
|
9
|
+
function readCookie(request, name) {
|
|
10
|
+
for (const part of (request.headers.get('cookie') ?? '').split(';')) {
|
|
11
|
+
const eq = part.indexOf('=');
|
|
12
|
+
if (eq !== -1 && part.slice(0, eq).trim() === name)
|
|
13
|
+
return decodeURIComponent(part.slice(eq + 1).trim());
|
|
14
|
+
}
|
|
15
|
+
return undefined;
|
|
16
|
+
}
|
|
17
|
+
function isHttps(request) {
|
|
18
|
+
const proto = request.headers.get('x-forwarded-proto')?.split(',')[0]?.trim();
|
|
19
|
+
return proto ? proto === 'https' : new URL(request.url).protocol === 'https:';
|
|
20
|
+
}
|
|
21
|
+
/** One shared password for the dashboard, with a signed, http-only session cookie. */
|
|
22
|
+
export function passwordAuth(options = {}) {
|
|
23
|
+
const cookieName = options.cookieName ?? 'np_session';
|
|
24
|
+
const days = options.sessionDays ?? 7;
|
|
25
|
+
const password = () => options.password ?? process.env.NEXTPLUGGABLE_PASSWORD ?? '';
|
|
26
|
+
// Sessions carry a fingerprint of the password, so changing it signs everyone out.
|
|
27
|
+
const fingerprint = (secret) => hmac(secret, `pw:${password()}`).slice(0, 16);
|
|
28
|
+
const cookie = (request, value, maxAge) => `${cookieName}=${encodeURIComponent(value)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${maxAge}${isHttps(request) ? '; Secure' : ''}`;
|
|
29
|
+
return {
|
|
30
|
+
kind: 'password',
|
|
31
|
+
get configured() {
|
|
32
|
+
return password().length > 0;
|
|
33
|
+
},
|
|
34
|
+
async getUser(request, env) {
|
|
35
|
+
if (!password())
|
|
36
|
+
return null;
|
|
37
|
+
const session = verifyToken(env.secret, readCookie(request, cookieName));
|
|
38
|
+
return session && session.fp === fingerprint(env.secret) ? { id: 'admin', name: 'Site owner' } : null;
|
|
39
|
+
},
|
|
40
|
+
async login(request, env) {
|
|
41
|
+
if (!password())
|
|
42
|
+
return jsonResponse({ error: 'No dashboard password has been set up yet.' }, 503);
|
|
43
|
+
const attempts = globals().loginAttempts;
|
|
44
|
+
const who = clientAddress(request);
|
|
45
|
+
const now = Date.now();
|
|
46
|
+
const recent = (attempts.get(who) ?? []).filter((t) => now - t < WINDOW_MS);
|
|
47
|
+
if (recent.length >= MAX_ATTEMPTS) {
|
|
48
|
+
attempts.set(who, recent);
|
|
49
|
+
return jsonResponse({ error: 'Too many attempts. Please wait a few minutes and try again.' }, 429);
|
|
50
|
+
}
|
|
51
|
+
const body = (await request.json().catch(() => null));
|
|
52
|
+
const given = typeof body?.password === 'string' ? body.password : '';
|
|
53
|
+
if (!given || !safeEqual(given, password())) {
|
|
54
|
+
recent.push(now);
|
|
55
|
+
attempts.set(who, recent);
|
|
56
|
+
return jsonResponse({ error: 'That password is not right.' }, 401);
|
|
57
|
+
}
|
|
58
|
+
attempts.delete(who);
|
|
59
|
+
const token = signToken(env.secret, { exp: now + days * 86_400_000, fp: fingerprint(env.secret) });
|
|
60
|
+
return jsonResponse({ user: { id: 'admin', name: 'Site owner' } }, 200, { 'set-cookie': cookie(request, token, days * 86_400) });
|
|
61
|
+
},
|
|
62
|
+
async logout(request) {
|
|
63
|
+
return jsonResponse({ ok: true }, 200, { 'set-cookie': cookie(request, '', 0) });
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export interface AdminUser {
|
|
2
|
+
id: string;
|
|
3
|
+
name?: string;
|
|
4
|
+
}
|
|
5
|
+
export interface AuthEnv {
|
|
6
|
+
/** The site secret, for signing sessions. */
|
|
7
|
+
secret: string;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Decides who may use the dashboard. Use `passwordAuth()` for a single shared
|
|
11
|
+
* password, or `customAuth()` to defer to the site's own login (Auth.js, Clerk, …).
|
|
12
|
+
*/
|
|
13
|
+
export interface AuthAdapter {
|
|
14
|
+
kind: 'password' | 'custom';
|
|
15
|
+
/** False when the adapter cannot work yet (e.g. no password set); the dashboard then shows setup steps. */
|
|
16
|
+
configured: boolean;
|
|
17
|
+
getUser(request: Request, env: AuthEnv): Promise<AdminUser | null>;
|
|
18
|
+
/** Password adapters: handle a sign-in attempt. */
|
|
19
|
+
login?(request: Request, env: AuthEnv): Promise<Response>;
|
|
20
|
+
logout?(request: Request, env: AuthEnv): Promise<Response>;
|
|
21
|
+
/** Custom adapters: where to send someone who is not signed in. */
|
|
22
|
+
loginUrl?: string;
|
|
23
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/cli.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export interface InitResult {
|
|
2
|
+
created: string[];
|
|
3
|
+
skipped: string[];
|
|
4
|
+
notes: string[];
|
|
5
|
+
/** Set when a dashboard password was generated. */
|
|
6
|
+
password?: string;
|
|
7
|
+
}
|
|
8
|
+
export declare function init(projectDir: string): Promise<InitResult>;
|
|
9
|
+
export declare function main(): Promise<number>;
|