@njinlabs/njin 0.10.3 → 0.11.0
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 +48 -0
- package/package.json +19 -4
- package/src/cli/build-assets.ts +4 -1
- package/src/cli/build.ts +47 -23
- package/src/cli/create.ts +35 -9
- package/src/cli/index.ts +2 -1
- package/src/cli/update.ts +93 -16
- package/src/config/module.ts +9 -0
- package/src/core/adapters/bun_filesystem.ts +13 -5
- package/src/core/adapters/s3.ts +18 -6
- package/src/core/admin_schema.ts +79 -0
- package/src/core/banner.ts +18 -4
- package/src/core/config.ts +58 -11
- package/src/core/helper.ts +4 -1
- package/src/core/html_page.ts +63 -0
- package/src/core/model/data_type/array.ts +16 -5
- package/src/core/model/data_type/boolean.ts +4 -1
- package/src/core/model/data_type/date.ts +4 -1
- package/src/core/model/data_type/email.ts +4 -1
- package/src/core/model/data_type/file.ts +17 -4
- package/src/core/model/data_type/multi_file.ts +9 -4
- package/src/core/model/data_type/numeric.ts +4 -1
- package/src/core/model/data_type/object.ts +8 -2
- package/src/core/model/data_type/relation.ts +21 -5
- package/src/core/model/data_type/relation_many.ts +24 -6
- package/src/core/model/data_type/richtext.ts +4 -1
- package/src/core/model/data_type/select.ts +17 -3
- package/src/core/model/data_type/text.ts +4 -1
- package/src/core/model/hooks.ts +42 -15
- package/src/core/model/index.ts +101 -37
- package/src/core/path_guard.ts +10 -3
- package/src/core/plugin.ts +8 -1
- package/src/core/public_url.ts +24 -0
- package/src/core/route.ts +2 -1
- package/src/core/vars/index.ts +22 -8
- package/src/core/worker.ts +22 -6
- package/src/models/file.ts +1 -1
- package/src/models/user.ts +1 -1
- package/src/modules/admin.ts +2 -2
- package/src/modules/analytics.ts +46 -26
- package/src/modules/api.ts +67 -95
- package/src/modules/auth.ts +25 -8
- package/src/modules/elysia.ts +1 -1
- package/src/modules/file.ts +50 -33
- package/src/modules/img.ts +33 -14
- package/src/modules/logger.ts +1 -1
- package/src/modules/mcp.ts +607 -0
- package/src/modules/mcp_token.ts +295 -0
- package/src/modules/mcp_upload.ts +313 -0
- package/src/modules/oauth.ts +579 -0
- package/src/modules/setup.ts +13 -5
- package/src/modules/surreal.ts +43 -13
- package/src/modules/users.ts +23 -5
- package/src/modules/vars.ts +1 -1
- package/src/modules/view.ts +88 -54
package/src/modules/surreal.ts
CHANGED
|
@@ -1,15 +1,16 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
+
import type { Engines } from "surrealdb";
|
|
3
|
+
import { createRemoteEngines, Surreal } from "surrealdb";
|
|
4
|
+
import type { z } from "zod";
|
|
2
5
|
import { getConfig } from "../core/config";
|
|
3
6
|
import { resolveSearchPlan } from "../core/model";
|
|
4
7
|
import { makeModule } from "../core/module";
|
|
5
|
-
import { Surreal, createRemoteEngines } from "surrealdb";
|
|
6
|
-
import type { Engines } from "surrealdb";
|
|
7
|
-
import type { z } from "zod";
|
|
8
8
|
|
|
9
9
|
const REMOTE_SCHEMES = ["ws://", "wss://", "http://", "https://"];
|
|
10
10
|
const EMBEDDED_SCHEMES = ["mem://", "rocksdb://", "surrealkv://"];
|
|
11
11
|
|
|
12
|
-
export const isRemotePath = (path: string) =>
|
|
12
|
+
export const isRemotePath = (path: string) =>
|
|
13
|
+
REMOTE_SCHEMES.some((scheme) => path.startsWith(scheme));
|
|
13
14
|
|
|
14
15
|
// Shared by every model's search index (see ../core/model/index.ts's read()) — `blank`
|
|
15
16
|
// tokenizer splits on whitespace only (unlike `class`, which also splits on punctuation:
|
|
@@ -28,7 +29,8 @@ const SEARCH_ANALYZER = "njin_search";
|
|
|
28
29
|
// schemaHash, so any future change here (e.g. a different ngram range) automatically
|
|
29
30
|
// invalidates the stored hash and re-runs the DEFINE on next boot instead of silently
|
|
30
31
|
// leaving an already-migrated DB on the old analyzer definition.
|
|
31
|
-
const SEARCH_ANALYZER_DEFINITION =
|
|
32
|
+
const SEARCH_ANALYZER_DEFINITION =
|
|
33
|
+
"TOKENIZERS blank FILTERS lowercase,ngram(3,10)";
|
|
32
34
|
|
|
33
35
|
// Records the hash of the last schema this DB was migrated to, so a worker booting against
|
|
34
36
|
// an already-migrated DB (idle-evict/crash respawn — every DEFINE below is idempotent but
|
|
@@ -44,7 +46,11 @@ const ensureTables = async (db: Surreal) => {
|
|
|
44
46
|
const { default: userModel } = await import("../models/user");
|
|
45
47
|
const { default: fileModel } = await import("../models/file");
|
|
46
48
|
|
|
47
|
-
const models: {
|
|
49
|
+
const models: {
|
|
50
|
+
prefix: string;
|
|
51
|
+
searchFields?: string[];
|
|
52
|
+
validation?: z.ZodObject;
|
|
53
|
+
}[] = [userModel, fileModel];
|
|
48
54
|
|
|
49
55
|
for (const factory of getConfig().models) {
|
|
50
56
|
const { default: model } = await factory();
|
|
@@ -53,6 +59,12 @@ const ensureTables = async (db: Surreal) => {
|
|
|
53
59
|
|
|
54
60
|
const prefixes = new Set<string>(models.map((model) => model.prefix));
|
|
55
61
|
prefixes.add("vars");
|
|
62
|
+
// Not a model, but queried by record id (see mcp_token.ts) — a never-DEFINE'd table makes
|
|
63
|
+
// that throw NotFoundError (see the note above the hash check below).
|
|
64
|
+
prefixes.add("mcp_token");
|
|
65
|
+
prefixes.add("oauth_client");
|
|
66
|
+
prefixes.add("oauth_code");
|
|
67
|
+
prefixes.add("mcp_upload");
|
|
56
68
|
|
|
57
69
|
// Resolve every search index's target table+field up front (also dedupes prefix+field in
|
|
58
70
|
// case two factories share a prefix, or a nested reference targets an already-indexed
|
|
@@ -67,8 +79,15 @@ const ensureTables = async (db: Surreal) => {
|
|
|
67
79
|
// throwing here — an authoring bug in a dotted entry is makeModel()'s job to catch, not
|
|
68
80
|
// table setup's.
|
|
69
81
|
const plan = model.validation
|
|
70
|
-
? resolveSearchPlan(
|
|
71
|
-
|
|
82
|
+
? resolveSearchPlan(
|
|
83
|
+
model.validation,
|
|
84
|
+
model.searchFields ?? [],
|
|
85
|
+
model.prefix,
|
|
86
|
+
)
|
|
87
|
+
: (model.searchFields ?? []).map((field) => ({
|
|
88
|
+
kind: "flat" as const,
|
|
89
|
+
field,
|
|
90
|
+
}));
|
|
72
91
|
|
|
73
92
|
for (const entry of plan) {
|
|
74
93
|
const target =
|
|
@@ -96,7 +115,9 @@ const ensureTables = async (db: Surreal) => {
|
|
|
96
115
|
// N DEFINEs it's gating.
|
|
97
116
|
await db.query(`DEFINE TABLE IF NOT EXISTS ${SCHEMA_META_TABLE} SCHEMALESS;`);
|
|
98
117
|
|
|
99
|
-
const [rows] = await db.query<[{ hash: string }[]]>(
|
|
118
|
+
const [rows] = await db.query<[{ hash: string }[]]>(
|
|
119
|
+
`SELECT hash FROM ${SCHEMA_META_TABLE}:current;`,
|
|
120
|
+
);
|
|
100
121
|
if (rows?.[0]?.hash === schemaHash) return;
|
|
101
122
|
|
|
102
123
|
for (const prefix of prefixes) {
|
|
@@ -108,7 +129,9 @@ const ensureTables = async (db: Surreal) => {
|
|
|
108
129
|
// definition is stale and needs replacing, not skipping. FULLTEXT, not SEARCH — this SurrealDB
|
|
109
130
|
// version renamed the index-type keyword; SEARCH ANALYZER ... is a parse error here even though
|
|
110
131
|
// older docs/examples use it.
|
|
111
|
-
await db.query(
|
|
132
|
+
await db.query(
|
|
133
|
+
`DEFINE ANALYZER OVERWRITE ${SEARCH_ANALYZER} ${SEARCH_ANALYZER_DEFINITION};`,
|
|
134
|
+
);
|
|
112
135
|
|
|
113
136
|
for (const { prefix: targetPrefix, field } of searchIndexes.values()) {
|
|
114
137
|
await db.query(
|
|
@@ -116,7 +139,9 @@ const ensureTables = async (db: Surreal) => {
|
|
|
116
139
|
);
|
|
117
140
|
}
|
|
118
141
|
|
|
119
|
-
await db.query(
|
|
142
|
+
await db.query(
|
|
143
|
+
`UPSERT ${SCHEMA_META_TABLE}:current SET hash = '${schemaHash}';`,
|
|
144
|
+
);
|
|
120
145
|
};
|
|
121
146
|
|
|
122
147
|
const surreal = makeModule(() => {
|
|
@@ -138,7 +163,9 @@ const surreal = makeModule(() => {
|
|
|
138
163
|
let engines: Engines;
|
|
139
164
|
if (isRemotePath(dbConfig.path)) {
|
|
140
165
|
engines = createRemoteEngines();
|
|
141
|
-
} else if (
|
|
166
|
+
} else if (
|
|
167
|
+
EMBEDDED_SCHEMES.some((scheme) => dbConfig.path.startsWith(scheme))
|
|
168
|
+
) {
|
|
142
169
|
// Dynamically imported (not a static top-level import) so a compiled build whose
|
|
143
170
|
// config resolves to a remote db.path can exclude @surrealdb/node entirely — see
|
|
144
171
|
// src/cli/build.ts, which externalizes this package for remote-only builds since its
|
|
@@ -163,7 +190,10 @@ const surreal = makeModule(() => {
|
|
|
163
190
|
await db.query(`DEFINE NAMESPACE IF NOT EXISTS \`${dbConfig.namespace}\`;`);
|
|
164
191
|
await db.use({ namespace: dbConfig.namespace });
|
|
165
192
|
await db.query(`DEFINE DATABASE IF NOT EXISTS \`${dbConfig.database}\`;`);
|
|
166
|
-
await db.use({
|
|
193
|
+
await db.use({
|
|
194
|
+
namespace: dbConfig.namespace,
|
|
195
|
+
database: dbConfig.database,
|
|
196
|
+
});
|
|
167
197
|
|
|
168
198
|
return {
|
|
169
199
|
// Deferred to spin(), not run here — ensureTables() imports every registered model
|
package/src/modules/users.ts
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
|
-
import { makeModule } from "../core/module";
|
|
2
1
|
import Elysia, { status } from "elysia";
|
|
3
2
|
import z from "zod";
|
|
3
|
+
import { makeModule } from "../core/module";
|
|
4
4
|
import auth from "./auth";
|
|
5
5
|
import elysia from "./elysia";
|
|
6
6
|
|
|
7
|
-
const stripPassword = <T extends { password: unknown }>({
|
|
7
|
+
const stripPassword = <T extends { password: unknown }>({
|
|
8
|
+
password: _,
|
|
9
|
+
...rest
|
|
10
|
+
}: T) => rest;
|
|
8
11
|
|
|
9
12
|
const users = makeModule(() => {
|
|
10
13
|
const fn = () => {};
|
|
@@ -18,7 +21,14 @@ const users = makeModule(() => {
|
|
|
18
21
|
.get(
|
|
19
22
|
"/",
|
|
20
23
|
async ({ query: { search, page, limit, sort, order, filters } }) => {
|
|
21
|
-
const result = await user.read({
|
|
24
|
+
const result = await user.read({
|
|
25
|
+
search,
|
|
26
|
+
page,
|
|
27
|
+
limit,
|
|
28
|
+
sort,
|
|
29
|
+
order,
|
|
30
|
+
filters,
|
|
31
|
+
});
|
|
22
32
|
|
|
23
33
|
return { data: result.data.map(stripPassword), meta: result.meta };
|
|
24
34
|
},
|
|
@@ -31,7 +41,13 @@ const users = makeModule(() => {
|
|
|
31
41
|
sort: z.coerce.string().optional(),
|
|
32
42
|
order: z.enum(["asc", "desc"]).default("asc"),
|
|
33
43
|
filters: z
|
|
34
|
-
.record(
|
|
44
|
+
.record(
|
|
45
|
+
z.string(),
|
|
46
|
+
z.union([
|
|
47
|
+
z.coerce.string(),
|
|
48
|
+
z.record(z.string(), z.coerce.string()),
|
|
49
|
+
]),
|
|
50
|
+
)
|
|
35
51
|
.optional(),
|
|
36
52
|
}),
|
|
37
53
|
},
|
|
@@ -73,7 +89,9 @@ const users = makeModule(() => {
|
|
|
73
89
|
// The only recovery path for a locked-out instance is a manual DB edit —
|
|
74
90
|
// setup is permanently disabled once any user exists, so block self-delete.
|
|
75
91
|
if (params.id === actingUser.id.id) {
|
|
76
|
-
return status(400, {
|
|
92
|
+
return status(400, {
|
|
93
|
+
message: "You cannot delete your own account",
|
|
94
|
+
});
|
|
77
95
|
}
|
|
78
96
|
|
|
79
97
|
const deleted = await user.destroy(params.id);
|
package/src/modules/vars.ts
CHANGED
package/src/modules/view.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import { getConfig } from "../core/config";
|
|
2
|
-
import { HttpError } from "../core/http_error";
|
|
3
|
-
import { makeModule } from "../core/module";
|
|
4
1
|
import { Edge } from "edge.js";
|
|
5
2
|
import Elysia from "elysia";
|
|
6
3
|
import { join } from "path";
|
|
4
|
+
import { getConfig } from "../core/config";
|
|
5
|
+
import { HttpError } from "../core/http_error";
|
|
6
|
+
import { makeModule } from "../core/module";
|
|
7
7
|
import elysia from "./elysia";
|
|
8
8
|
|
|
9
9
|
const isDev = process.env.NODE_ENV !== "production";
|
|
@@ -31,7 +31,9 @@ export const buildViteGlobal = async (): Promise<ViteGlobal> => {
|
|
|
31
31
|
await server.listen();
|
|
32
32
|
server.printUrls();
|
|
33
33
|
|
|
34
|
-
const url =
|
|
34
|
+
const url =
|
|
35
|
+
server.resolvedUrls?.local[0]?.replace(/\/$/, "") ??
|
|
36
|
+
"http://localhost:5173";
|
|
35
37
|
|
|
36
38
|
process.once("SIGINT", () => server.close());
|
|
37
39
|
process.once("SIGTERM", () => server.close());
|
|
@@ -59,7 +61,10 @@ export const buildViteGlobal = async (): Promise<ViteGlobal> => {
|
|
|
59
61
|
};
|
|
60
62
|
}
|
|
61
63
|
|
|
62
|
-
const manifest = (await manifestFile.json()) as Record<
|
|
64
|
+
const manifest = (await manifestFile.json()) as Record<
|
|
65
|
+
string,
|
|
66
|
+
ViteManifestChunk
|
|
67
|
+
>;
|
|
63
68
|
|
|
64
69
|
return {
|
|
65
70
|
asset: (entry) => {
|
|
@@ -68,7 +73,10 @@ export const buildViteGlobal = async (): Promise<ViteGlobal> => {
|
|
|
68
73
|
if (entry.endsWith(".css")) {
|
|
69
74
|
return `<link rel="stylesheet" href="/${chunk.file}">`;
|
|
70
75
|
}
|
|
71
|
-
const styles =
|
|
76
|
+
const styles =
|
|
77
|
+
chunk.css
|
|
78
|
+
?.map((f) => `<link rel="stylesheet" href="/${f}">`)
|
|
79
|
+
.join("\n") ?? "";
|
|
72
80
|
const script = `<script type="module" src="/${chunk.file}"></script>`;
|
|
73
81
|
return [styles, script].filter(Boolean).join("\n");
|
|
74
82
|
},
|
|
@@ -117,7 +125,8 @@ const view = makeModule(() => {
|
|
|
117
125
|
if (!isDev) {
|
|
118
126
|
controller.get("/assets/*", async ({ params }) => {
|
|
119
127
|
const file = Bun.file(join(publicDir(), "assets", params["*"]));
|
|
120
|
-
if (!(await file.exists()))
|
|
128
|
+
if (!(await file.exists()))
|
|
129
|
+
return new Response("Not Found", { status: 404 });
|
|
121
130
|
return file;
|
|
122
131
|
});
|
|
123
132
|
}
|
|
@@ -138,48 +147,57 @@ const view = makeModule(() => {
|
|
|
138
147
|
const route = fileToRoute(file);
|
|
139
148
|
const template = `pages/${file.replace(/\\/g, "/").replace(/\.edge$/, "")}`;
|
|
140
149
|
|
|
141
|
-
controller.get(
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
return new Response(
|
|
172
|
-
|
|
150
|
+
controller.get(
|
|
151
|
+
route,
|
|
152
|
+
async ({ params, query, path, request, server }) => {
|
|
153
|
+
// Object property — avoids TypeScript's overly-aggressive narrowing of
|
|
154
|
+
// closure-assigned variables to `never`.
|
|
155
|
+
const ctx: { abortErr: HttpError | null } = { abortErr: null };
|
|
156
|
+
|
|
157
|
+
try {
|
|
158
|
+
const html = await edge.render(template, {
|
|
159
|
+
params,
|
|
160
|
+
query,
|
|
161
|
+
request: { path, url: request.url },
|
|
162
|
+
abort: (statusCode: number, message?: string) => {
|
|
163
|
+
ctx.abortErr = new HttpError(statusCode, message);
|
|
164
|
+
throw ctx.abortErr;
|
|
165
|
+
},
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
// Fire-and-forget — never awaited, so it can't add latency to the response.
|
|
169
|
+
import("./analytics").then(
|
|
170
|
+
({ default: analytics, resolveClientIp, resolveRequestOrigin }) =>
|
|
171
|
+
analytics().track({
|
|
172
|
+
path,
|
|
173
|
+
referrer: request.headers.get("referer"),
|
|
174
|
+
userAgent: request.headers.get("user-agent"),
|
|
175
|
+
ip: resolveClientIp(request, server),
|
|
176
|
+
requestUrl: resolveRequestOrigin(request),
|
|
177
|
+
}),
|
|
178
|
+
);
|
|
179
|
+
|
|
180
|
+
return new Response(html, {
|
|
181
|
+
headers: { "Content-Type": "text/html; charset=utf-8" },
|
|
182
|
+
});
|
|
183
|
+
} catch (err) {
|
|
184
|
+
if (ctx.abortErr) {
|
|
185
|
+
return new Response(
|
|
186
|
+
await renderHttpError(edge, viewsDir, ctx.abortErr),
|
|
187
|
+
{
|
|
188
|
+
status: ctx.abortErr.statusCode,
|
|
189
|
+
headers: { "Content-Type": "text/html; charset=utf-8" },
|
|
190
|
+
},
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
if (!isDev) throw err;
|
|
194
|
+
return new Response(renderErrorPage(err as Error, template, path), {
|
|
195
|
+
status: 500,
|
|
173
196
|
headers: { "Content-Type": "text/html; charset=utf-8" },
|
|
174
197
|
});
|
|
175
198
|
}
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
status: 500,
|
|
179
|
-
headers: { "Content-Type": "text/html; charset=utf-8" },
|
|
180
|
-
});
|
|
181
|
-
}
|
|
182
|
-
});
|
|
199
|
+
},
|
|
200
|
+
);
|
|
183
201
|
}
|
|
184
202
|
|
|
185
203
|
// Catch-all — must be registered last so specific routes take priority
|
|
@@ -189,10 +207,13 @@ const view = makeModule(() => {
|
|
|
189
207
|
if (await staticFile.exists()) return staticFile;
|
|
190
208
|
}
|
|
191
209
|
|
|
192
|
-
return new Response(
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
210
|
+
return new Response(
|
|
211
|
+
await renderHttpError(edge, viewsDir, new HttpError(404)),
|
|
212
|
+
{
|
|
213
|
+
status: 404,
|
|
214
|
+
headers: { "Content-Type": "text/html; charset=utf-8" },
|
|
215
|
+
},
|
|
216
|
+
);
|
|
196
217
|
});
|
|
197
218
|
|
|
198
219
|
elysia().use(controller);
|
|
@@ -203,8 +224,14 @@ const view = makeModule(() => {
|
|
|
203
224
|
return fn;
|
|
204
225
|
});
|
|
205
226
|
|
|
206
|
-
export async function renderHttpError(
|
|
207
|
-
|
|
227
|
+
export async function renderHttpError(
|
|
228
|
+
edge: Edge,
|
|
229
|
+
viewsDir: string,
|
|
230
|
+
error: HttpError,
|
|
231
|
+
): Promise<string> {
|
|
232
|
+
const templateFile = Bun.file(
|
|
233
|
+
join(viewsDir, `errors/${error.statusCode}.edge`),
|
|
234
|
+
);
|
|
208
235
|
if (await templateFile.exists()) {
|
|
209
236
|
try {
|
|
210
237
|
return await edge.render(`errors/${error.statusCode}`, {
|
|
@@ -212,7 +239,10 @@ export async function renderHttpError(edge: Edge, viewsDir: string, error: HttpE
|
|
|
212
239
|
message: error.message,
|
|
213
240
|
});
|
|
214
241
|
} catch (e) {
|
|
215
|
-
console.error(
|
|
242
|
+
console.error(
|
|
243
|
+
`[view] failed to render errors/${error.statusCode}.edge:`,
|
|
244
|
+
e,
|
|
245
|
+
);
|
|
216
246
|
}
|
|
217
247
|
}
|
|
218
248
|
const code = error.statusCode;
|
|
@@ -222,7 +252,11 @@ export async function renderHttpError(edge: Edge, viewsDir: string, error: HttpE
|
|
|
222
252
|
</head><body><div class="c"><div class="n">${code}</div><p class="m">${error.message}</p><a href="/">← Back to home</a></div></body></html>`;
|
|
223
253
|
}
|
|
224
254
|
|
|
225
|
-
export function renderErrorPage(
|
|
255
|
+
export function renderErrorPage(
|
|
256
|
+
error: Error,
|
|
257
|
+
template: string,
|
|
258
|
+
path: string,
|
|
259
|
+
): string {
|
|
226
260
|
const stack = (error.stack ?? "").replace(/</g, "<").replace(/>/g, ">");
|
|
227
261
|
const message = error.message.replace(/</g, "<").replace(/>/g, ">");
|
|
228
262
|
|