@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.
Files changed (55) hide show
  1. package/README.md +48 -0
  2. package/package.json +19 -4
  3. package/src/cli/build-assets.ts +4 -1
  4. package/src/cli/build.ts +47 -23
  5. package/src/cli/create.ts +35 -9
  6. package/src/cli/index.ts +2 -1
  7. package/src/cli/update.ts +93 -16
  8. package/src/config/module.ts +9 -0
  9. package/src/core/adapters/bun_filesystem.ts +13 -5
  10. package/src/core/adapters/s3.ts +18 -6
  11. package/src/core/admin_schema.ts +79 -0
  12. package/src/core/banner.ts +18 -4
  13. package/src/core/config.ts +58 -11
  14. package/src/core/helper.ts +4 -1
  15. package/src/core/html_page.ts +63 -0
  16. package/src/core/model/data_type/array.ts +16 -5
  17. package/src/core/model/data_type/boolean.ts +4 -1
  18. package/src/core/model/data_type/date.ts +4 -1
  19. package/src/core/model/data_type/email.ts +4 -1
  20. package/src/core/model/data_type/file.ts +17 -4
  21. package/src/core/model/data_type/multi_file.ts +9 -4
  22. package/src/core/model/data_type/numeric.ts +4 -1
  23. package/src/core/model/data_type/object.ts +8 -2
  24. package/src/core/model/data_type/relation.ts +21 -5
  25. package/src/core/model/data_type/relation_many.ts +24 -6
  26. package/src/core/model/data_type/richtext.ts +4 -1
  27. package/src/core/model/data_type/select.ts +17 -3
  28. package/src/core/model/data_type/text.ts +4 -1
  29. package/src/core/model/hooks.ts +42 -15
  30. package/src/core/model/index.ts +101 -37
  31. package/src/core/path_guard.ts +10 -3
  32. package/src/core/plugin.ts +8 -1
  33. package/src/core/public_url.ts +24 -0
  34. package/src/core/route.ts +2 -1
  35. package/src/core/vars/index.ts +22 -8
  36. package/src/core/worker.ts +22 -6
  37. package/src/models/file.ts +1 -1
  38. package/src/models/user.ts +1 -1
  39. package/src/modules/admin.ts +2 -2
  40. package/src/modules/analytics.ts +46 -26
  41. package/src/modules/api.ts +67 -95
  42. package/src/modules/auth.ts +25 -8
  43. package/src/modules/elysia.ts +1 -1
  44. package/src/modules/file.ts +50 -33
  45. package/src/modules/img.ts +33 -14
  46. package/src/modules/logger.ts +1 -1
  47. package/src/modules/mcp.ts +607 -0
  48. package/src/modules/mcp_token.ts +295 -0
  49. package/src/modules/mcp_upload.ts +313 -0
  50. package/src/modules/oauth.ts +579 -0
  51. package/src/modules/setup.ts +13 -5
  52. package/src/modules/surreal.ts +43 -13
  53. package/src/modules/users.ts +23 -5
  54. package/src/modules/vars.ts +1 -1
  55. package/src/modules/view.ts +88 -54
@@ -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) => REMOTE_SCHEMES.some((scheme) => path.startsWith(scheme));
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 = "TOKENIZERS blank FILTERS lowercase,ngram(3,10)";
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: { prefix: string; searchFields?: string[]; validation?: z.ZodObject }[] = [userModel, fileModel];
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(model.validation, model.searchFields ?? [], model.prefix)
71
- : (model.searchFields ?? []).map((field) => ({ kind: "flat" as const, field }));
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 }[]]>(`SELECT hash FROM ${SCHEMA_META_TABLE}:current;`);
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(`DEFINE ANALYZER OVERWRITE ${SEARCH_ANALYZER} ${SEARCH_ANALYZER_DEFINITION};`);
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(`UPSERT ${SCHEMA_META_TABLE}:current SET hash = '${schemaHash}';`);
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 (EMBEDDED_SCHEMES.some((scheme) => dbConfig.path.startsWith(scheme))) {
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({ namespace: dbConfig.namespace, database: dbConfig.database });
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
@@ -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 }>({ password: _, ...rest }: T) => rest;
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({ search, page, limit, sort, order, filters });
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(z.string(), z.union([z.coerce.string(), z.record(z.string(), z.coerce.string())]))
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, { message: "You cannot delete your own account" });
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);
@@ -1,6 +1,6 @@
1
+ import Elysia from "elysia";
1
2
  import { getConfig } from "../core/config";
2
3
  import { makeModule } from "../core/module";
3
- import Elysia from "elysia";
4
4
  import auth from "./auth";
5
5
  import elysia from "./elysia";
6
6
 
@@ -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 = server.resolvedUrls?.local[0]?.replace(/\/$/, "") ?? "http://localhost:5173";
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<string, ViteManifestChunk>;
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 = chunk.css?.map((f) => `<link rel="stylesheet" href="/${f}">`).join("\n") ?? "";
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())) return new Response("Not Found", { status: 404 });
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(route, async ({ params, query, path, request, server }) => {
142
- // Object property — avoids TypeScript's overly-aggressive narrowing of
143
- // closure-assigned variables to `never`.
144
- const ctx: { abortErr: HttpError | null } = { abortErr: null };
145
-
146
- try {
147
- const html = await edge.render(template, {
148
- params,
149
- query,
150
- request: { path, url: request.url },
151
- abort: (statusCode: number, message?: string) => {
152
- ctx.abortErr = new HttpError(statusCode, message);
153
- throw ctx.abortErr;
154
- },
155
- });
156
-
157
- // Fire-and-forget — never awaited, so it can't add latency to the response.
158
- import("./analytics").then(({ default: analytics, resolveClientIp, resolveRequestOrigin }) =>
159
- analytics().track({
160
- path,
161
- referrer: request.headers.get("referer"),
162
- userAgent: request.headers.get("user-agent"),
163
- ip: resolveClientIp(request, server),
164
- requestUrl: resolveRequestOrigin(request),
165
- }),
166
- );
167
-
168
- return new Response(html, { headers: { "Content-Type": "text/html; charset=utf-8" } });
169
- } catch (err) {
170
- if (ctx.abortErr) {
171
- return new Response(await renderHttpError(edge, viewsDir, ctx.abortErr), {
172
- status: ctx.abortErr.statusCode,
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
- if (!isDev) throw err;
177
- return new Response(renderErrorPage(err as Error, template, path), {
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(await renderHttpError(edge, viewsDir, new HttpError(404)), {
193
- status: 404,
194
- headers: { "Content-Type": "text/html; charset=utf-8" },
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(edge: Edge, viewsDir: string, error: HttpError): Promise<string> {
207
- const templateFile = Bun.file(join(viewsDir, `errors/${error.statusCode}.edge`));
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(`[view] failed to render errors/${error.statusCode}.edge:`, e);
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(error: Error, template: string, path: string): string {
255
+ export function renderErrorPage(
256
+ error: Error,
257
+ template: string,
258
+ path: string,
259
+ ): string {
226
260
  const stack = (error.stack ?? "").replace(/</g, "&lt;").replace(/>/g, "&gt;");
227
261
  const message = error.message.replace(/</g, "&lt;").replace(/>/g, "&gt;");
228
262