@njinlabs/njin 0.11.0-beta.1 → 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 CHANGED
@@ -338,6 +338,21 @@ All `/api/*` endpoints require `Authorization: Bearer <token>`.
338
338
 
339
339
  njin exposes a remote [MCP](https://modelcontextprotocol.io) server at `POST /mcp` (Streamable HTTP), so an agent such as Claude Cowork or Claude Desktop can read and edit content, change settings and upload files.
340
340
 
341
+ **Other AI clients.** Any MCP client that speaks Streamable HTTP and OAuth with dynamic client registration can connect. These OAuth callbacks are accepted out of the box: Claude, ChatGPT, Cursor, VS Code, and any loopback address (`localhost` / `127.0.0.1` — what Gemini CLI, Claude Code, Codex CLI, MCP Inspector and similar desktop clients use). For a client with its own hosted callback, allow it in `config.ts` — and only for clients you trust, since it is where the sign-in code is sent:
342
+
343
+ ```ts
344
+ export default defineConfig({
345
+ mcp: {
346
+ redirectUris: [
347
+ "https://app.example.com/oauth/callback", // exact
348
+ "https://client.example.com/connectors/*", // path prefix
349
+ ],
350
+ },
351
+ });
352
+ ```
353
+
354
+ A client that can't do OAuth but can send a header can use a token from `POST /api/mcp-token` instead.
355
+
341
356
  **Connecting Claude:** add a custom connector and enter only the URL, `https://your-site.com/mcp`. Claude opens an njin sign-in page, you log in with your normal njin account and click **Allow** — no token to copy. The site must be reachable from the internet over HTTPS; set `publicUrl` in `config.ts` if it sits behind a proxy that rewrites the host.
342
357
 
343
358
  | Tool | |
@@ -349,6 +364,8 @@ njin exposes a remote [MCP](https://modelcontextprotocol.io) server at `POST /mc
349
364
  | `create_upload_url`, `check_upload` | Add files (below) |
350
365
  | `list_files`, `get_file`, `delete_file` | Manage uploaded files |
351
366
 
367
+ **Links.** A relation or file field takes the related record's id as a plain string, and that is what `list_models` tells the agent. If an agent sends an object instead (`{ "id": "..." }`, or a whole record read earlier) it is reduced to the id before saving — otherwise njin would store a dead embedded copy rather than a link. Created and updated records come back with their relations expanded, with a `_warnings` entry if a link points at nothing, and reads flag records that already hold an embedded `{ id }` copy so they can be repaired.
368
+
352
369
  **Files.** File bytes can't go through a tool call, so `create_upload_url` returns a short-lived link (10 minutes, up to 10 files of 10 MB). The agent uploads with `curl -F file=@photo.jpg <url>`, or — if it has no shell or network — shows the link so you can open it and drop the file. `check_upload` then returns the file ids to put in a model's file field. Only safe types are accepted (images except SVG, PDF, Office documents, audio, video, zip, fonts); HTML, SVG and scripts are rejected because `/uploads` is served from the site's own origin.
353
370
 
354
371
  **Managing connections.** Every connected agent is a token you can revoke:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@njinlabs/njin",
3
- "version": "0.11.0-beta.1",
3
+ "version": "0.11.0",
4
4
  "description": "A modern framework for building company profiles, landing pages, and content-driven websites.",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -27,10 +27,35 @@ const stripHiddenFields = (node: any): void => {
27
27
  // non-JSON-representable renderAs types (relation/multi_relation/file) into
28
28
  // plain JSON-schema shapes the admin panel can render a form from, and drops
29
29
  // any field marked hideForm: true so it never reaches the admin panel.
30
- export const toAdminSchema = (schema: z.ZodObject) => {
30
+ //
31
+ // `forAgent` describes every link (relation/file/multi_*) as what it takes on the wire: the
32
+ // related record's id as a plain string. The admin shape for a single relation is an object
33
+ // `{ id }`, which is right for the panel's form but wrong for an agent: a relation written as an
34
+ // object is stored as an embedded copy instead of a link to the record, so it never expands.
35
+ export const toAdminSchema = (
36
+ schema: z.ZodObject,
37
+ options: { forAgent?: boolean } = {},
38
+ ) => {
31
39
  const jsonSchema = schema.toJSONSchema({
32
40
  unrepresentable: "any",
33
41
  override: (ctx) => {
42
+ if (options.forAgent) {
43
+ const kind = ctx.jsonSchema.renderAs;
44
+ const target = String(ctx.jsonSchema.model ?? "");
45
+
46
+ if (kind === "relation" || kind === "file") {
47
+ ctx.jsonSchema.type = "string";
48
+ ctx.jsonSchema.description = `Id of a "${target}" record, as a plain string (not an object).`;
49
+ return;
50
+ }
51
+ if (kind === "multi_relation" || kind === "multi_file") {
52
+ ctx.jsonSchema.type = "array";
53
+ ctx.jsonSchema.items = { type: "string" };
54
+ ctx.jsonSchema.description = `Ids of "${target}" records, as plain strings (not objects).`;
55
+ return;
56
+ }
57
+ }
58
+
34
59
  if (ctx.jsonSchema.renderAs === "relation") {
35
60
  ctx.jsonSchema.type = "object";
36
61
  ctx.jsonSchema.properties = {
@@ -45,6 +45,13 @@ export type NjinConfig = {
45
45
  // Optional: falls back to the request's own origin (see core/public_url.ts), which is
46
46
  // enough for a single-host setup without a proxy rewriting Host.
47
47
  publicUrl?: string;
48
+ mcp?: {
49
+ // Extra OAuth callback URLs an MCP client may register, on top of the built-in ones (Claude,
50
+ // ChatGPT, Cursor, VS Code, and any loopback address). An entry is matched exactly, or as a
51
+ // path prefix when it ends in "*" ("https://app.example.com/oauth/*"). Only add callbacks of
52
+ // clients you trust: it is where the authorization code is sent after a user signs in.
53
+ redirectUris?: string[];
54
+ };
48
55
  // Base directory every project-relative path (src/views, _admin, public, upload dirs)
49
56
  // resolves against. Defaults to process.cwd() — only needs overriding by a build step
50
57
  // (see build-worker.ts) that bakes in an absolute path for a runtime whose cwd isn't
@@ -78,6 +85,7 @@ export type NjinConfig = {
78
85
  export type ResolvedConfig = {
79
86
  port: number;
80
87
  publicUrl?: string;
88
+ mcp: { redirectUris: string[] };
81
89
  rootDir: string;
82
90
  db: {
83
91
  path: string;
@@ -149,6 +157,7 @@ export const loadConfig = async (preloaded?: NjinConfig): Promise<void> => {
149
157
  resolved = {
150
158
  port: userConfig.port ?? 3000,
151
159
  publicUrl: userConfig.publicUrl,
160
+ mcp: { redirectUris: userConfig.mcp?.redirectUris ?? [] },
152
161
  rootDir: userConfig.rootDir ?? process.cwd(),
153
162
  db: {
154
163
  path: userConfig.db?.path ?? "rocksdb://data",
@@ -45,7 +45,7 @@ label{display:block;font-size:14px;margin:12px 0 4px}
45
45
  input[type=email],input[type=password]{width:100%;padding:10px 12px;border:1px solid var(--line);border-radius:8px;background:transparent;color:inherit;font:inherit}
46
46
  .row{display:flex;gap:8px;margin-top:20px}
47
47
  button{flex:1;padding:10px 12px;border-radius:8px;border:1px solid var(--line);background:transparent;color:inherit;font:inherit;cursor:pointer}
48
- button.primary{background:var(--accent);color:var(--accent-fg);border-color:var(--accent)}
48
+ button.primary{background:var(--accent);color:var(--accent-fg);border-color:var(--accent);order:1}
49
49
  .error{color:var(--danger);font-size:14px;margin:0 0 12px}
50
50
  input[type=file]{width:100%;padding:32px 12px;border:2px dashed var(--line);border-radius:8px;background:transparent;color:inherit;font:inherit;cursor:pointer}
51
51
  .ok{color:var(--fg)}
@@ -21,7 +21,7 @@ const INSTRUCTIONS = `Manage the content of an njin website.
21
21
  Start with list_models: it returns every content model and settings (vars) group with its JSON schema. Then use read_records / get_record to look at existing content before changing it.
22
22
 
23
23
  Field conventions:
24
- - A relation field takes the id of the related record (a string, not the whole record).
24
+ - A relation or file field takes the id of the related record as a plain string — never an object and never the whole record (an object is stored as a dead copy, not a link). Records you read, create or update come back with their relations expanded one level. A "_warnings" entry means a link points at a record that does not exist, or holds an embedded {id} object instead of a link: fix it with update_record, sending the plain id string.
25
25
  - A field with renderAs "file" takes the id of a file record, not a URL.
26
26
  - update_record and update_vars only change the fields you send; omitted fields are kept.
27
27
  - Validation errors name the offending field — fix it and retry instead of guessing.
@@ -52,6 +52,126 @@ const ok = (data: unknown): ToolResult => ({
52
52
  const bareId = (prefix: string, id: string) =>
53
53
  id.startsWith(`${prefix}:`) ? id.slice(prefix.length + 1) : id;
54
54
 
55
+ const RELATION_KINDS = new Set([
56
+ "relation",
57
+ "multi_relation",
58
+ "file",
59
+ "multi_file",
60
+ ]);
61
+
62
+ type RelationField = { name: string; many: boolean; target: string };
63
+
64
+ // A model or a vars group — both carry the zod schema the link fields are read from.
65
+ type HasSchema = { validation: z.ZodObject };
66
+
67
+ const relationFieldsOf = (model: HasSchema): RelationField[] =>
68
+ Object.entries(model.validation.shape).flatMap(([name, field]) => {
69
+ const meta = (field as z.ZodType).meta() as
70
+ | { renderAs?: string; model?: string }
71
+ | undefined;
72
+ if (!RELATION_KINDS.has(meta?.renderAs ?? "")) return [];
73
+
74
+ return [
75
+ {
76
+ name,
77
+ many:
78
+ meta?.renderAs === "multi_relation" ||
79
+ meta?.renderAs === "multi_file",
80
+ target: meta?.model ?? "",
81
+ },
82
+ ];
83
+ });
84
+
85
+ const toLinkId = (target: string, value: unknown) => {
86
+ const raw =
87
+ value && typeof value === "object" && "id" in value
88
+ ? (value as { id: unknown }).id
89
+ : value;
90
+
91
+ return typeof raw === "string" ? bareId(target, raw) : value;
92
+ };
93
+
94
+ // A relation written as an object (`{ id }`, or a whole record echoed back from a read) is stored
95
+ // by njin as an embedded copy, not a link — it never expands and goes stale. Agents do send that
96
+ // shape, so reduce every link field to the plain id string before validation turns it into a
97
+ // real record link. Also drops a "table:" prefix that doesn't belong in a link.
98
+ const normalizeRelations = (
99
+ model: HasSchema,
100
+ data: Record<string, unknown>,
101
+ ) => {
102
+ const next = { ...data };
103
+
104
+ for (const { name, many, target } of relationFieldsOf(model)) {
105
+ const value = next[name];
106
+ if (value === undefined || value === null) continue;
107
+
108
+ next[name] =
109
+ many && Array.isArray(value)
110
+ ? value.map((item) => toLinkId(target, item))
111
+ : toLinkId(target, value);
112
+ }
113
+
114
+ return next;
115
+ };
116
+
117
+ // An expanded link always carries the target's own fields; a bare `{ id }` is what an embedded
118
+ // stub looks like (a link written as an object before this was normalised).
119
+ //
120
+ // A RecordId (a link that wasn't expanded) has no own enumerable keys, so it must be ruled out
121
+ // first — `[].every(...)` is true and every healthy bare link would be reported as a stub.
122
+ const isStub = (value: unknown) => {
123
+ if (!value || typeof value !== "object") return false;
124
+ if (Array.isArray(value) || value instanceof RecordId) return false;
125
+
126
+ const keys = Object.keys(value);
127
+ return keys.length > 0 && keys.every((key) => key === "id");
128
+ };
129
+
130
+ const stubFieldsOf = (model: HasSchema, row: Record<string, unknown>) =>
131
+ relationFieldsOf(model)
132
+ .filter(({ name, many }) =>
133
+ many
134
+ ? Array.isArray(row[name]) && (row[name] as unknown[]).some(isStub)
135
+ : isStub(row[name]),
136
+ )
137
+ .map(({ name }) => name);
138
+
139
+ const stubWarning = (field: string, recordId?: string) =>
140
+ `${recordId ? `Record ${recordId}: ` : ""}"${field}" holds an embedded {id} object, not a link, so it cannot expand. Fix it with update_record, sending "${field}" as the plain id string.`;
141
+
142
+ // create()/update() hand back the row exactly as stored, so every relation field is a bare id —
143
+ // the agent can't tell a link that resolved from one pointing at nothing (SurrealDB drops a
144
+ // dangling link from a FETCH instead of erroring). Re-read through show(), the same FETCH
145
+ // get_record uses, so related records come back expanded, and flag any link that didn't resolve.
146
+ const readBack = async (model: Model, written: Record<string, unknown>) => {
147
+ const id =
148
+ written.id instanceof RecordId ? String(written.id.id) : String(written.id);
149
+ const shown = ((await model.show(id)) ?? written) as Record<string, unknown>;
150
+
151
+ const warnings: string[] = [];
152
+ for (const { name: field } of relationFieldsOf(model)) {
153
+ const stored = written[field];
154
+ if (stored === null || stored === undefined) continue;
155
+
156
+ const resolved = shown[field];
157
+ const isRecord = (value: unknown) => !!value && typeof value === "object";
158
+ const missing = Array.isArray(stored)
159
+ ? stored.length -
160
+ (Array.isArray(resolved) ? resolved.filter(isRecord).length : 0)
161
+ : isRecord(resolved)
162
+ ? 0
163
+ : 1;
164
+
165
+ if (missing > 0) {
166
+ warnings.push(
167
+ `"${field}": ${missing} linked record(s) do not exist — check the id(s) you sent.`,
168
+ );
169
+ }
170
+ }
171
+
172
+ return warnings.length ? { ...shown, _warnings: warnings } : shown;
173
+ };
174
+
55
175
  const fail = (message: string): ToolResult => ({
56
176
  content: [{ type: "text", text: message }],
57
177
  isError: true,
@@ -96,12 +216,12 @@ const mcp = makeModule(() => {
96
216
  models: [...models.values()].map((model) => ({
97
217
  name: model.name,
98
218
  prefix: model.prefix,
99
- schema: toAdminSchema(model.validation),
219
+ schema: toAdminSchema(model.validation, { forAgent: true }),
100
220
  })),
101
221
  vars: [...groups.values()].map((group) => ({
102
222
  name: group.name,
103
223
  prefix: group.prefix,
104
- schema: toAdminSchema(group.validation),
224
+ schema: toAdminSchema(group.validation, { forAgent: true }),
105
225
  })),
106
226
  };
107
227
 
@@ -185,7 +305,30 @@ const mcp = makeModule(() => {
185
305
  async ({ model, ...options }) =>
186
306
  run(async () => {
187
307
  audit("read_records", model);
188
- return getModel(model).read(options);
308
+ // [] means "not specified", same as an empty value over REST — passing it through
309
+ // would suppress every FETCH and leave relations as bare ids.
310
+ const populate =
311
+ Array.isArray(options.populate) && options.populate.length === 0
312
+ ? undefined
313
+ : options.populate;
314
+ const target = getModel(model);
315
+ const result = await target.read({ ...options, populate });
316
+
317
+ const warnings = (result.data as Record<string, unknown>[]).flatMap(
318
+ (row) =>
319
+ stubFieldsOf(target, row).map((field) =>
320
+ stubWarning(
321
+ field,
322
+ row.id instanceof RecordId
323
+ ? String(row.id.id)
324
+ : String(row.id),
325
+ ),
326
+ ),
327
+ );
328
+
329
+ return warnings.length
330
+ ? { ...result, _warnings: warnings.slice(0, 20) }
331
+ : result;
189
332
  }),
190
333
  );
191
334
 
@@ -200,9 +343,16 @@ const mcp = makeModule(() => {
200
343
  async ({ model, id }) =>
201
344
  run(async () => {
202
345
  audit("get_record", model, id);
203
- const record = await getModel(model).show(bareId(model, id));
346
+ const target = getModel(model);
347
+ const record = (await target.show(bareId(model, id))) as
348
+ | Record<string, unknown>
349
+ | undefined;
204
350
  if (!record) throw new Error(`No ${model} record with id "${id}"`);
205
- return record;
351
+
352
+ const stubs = stubFieldsOf(target, record);
353
+ return stubs.length
354
+ ? { ...record, _warnings: stubs.map((f) => stubWarning(f)) }
355
+ : record;
206
356
  }),
207
357
  );
208
358
 
@@ -221,7 +371,14 @@ const mcp = makeModule(() => {
221
371
  run(async () => {
222
372
  audit("create_record", model);
223
373
  const target = getModel(model);
224
- return target.create(target.validation.parse(data) as never);
374
+ return readBack(
375
+ target,
376
+ (await target.create(
377
+ target.validation.parse(
378
+ normalizeRelations(target, data),
379
+ ) as never,
380
+ )) as Record<string, unknown>,
381
+ );
225
382
  }),
226
383
  );
227
384
 
@@ -242,9 +399,14 @@ const mcp = makeModule(() => {
242
399
  run(async () => {
243
400
  audit("update_record", model, id);
244
401
  const target = getModel(model);
245
- return target.update(
246
- bareId(model, id),
247
- target.validation.partial().parse(data) as never,
402
+ return readBack(
403
+ target,
404
+ (await target.update(
405
+ bareId(model, id),
406
+ target.validation
407
+ .partial()
408
+ .parse(normalizeRelations(target, data)) as never,
409
+ )) as Record<string, unknown>,
248
410
  );
249
411
  }),
250
412
  );
@@ -269,14 +431,20 @@ const mcp = makeModule(() => {
269
431
  {
270
432
  title: "Get settings",
271
433
  description:
272
- "Get the current values of a settings (vars) group, defaults filled in.",
434
+ "Get the current values of a settings (vars) group, defaults filled in. Relation and file fields come back as ids (use get_record / get_file to look them up).",
273
435
  inputSchema: { group: z.string().describe("Group prefix") },
274
436
  annotations: { readOnlyHint: true },
275
437
  },
276
438
  async ({ group }) =>
277
439
  run(async () => {
278
440
  audit("get_vars", group);
279
- return getGroup(group).get();
441
+ const target = getGroup(group);
442
+ const values = (await target.get()) as Record<string, unknown>;
443
+
444
+ const stubs = stubFieldsOf(target, values);
445
+ return stubs.length
446
+ ? { ...values, _warnings: stubs.map((f) => stubWarning(f)) }
447
+ : values;
280
448
  }),
281
449
  );
282
450
 
@@ -297,7 +465,9 @@ const mcp = makeModule(() => {
297
465
  audit("update_vars", group);
298
466
  const target = getGroup(group);
299
467
  return target.update(
300
- target.validation.partial().parse(data) as never,
468
+ target.validation
469
+ .partial()
470
+ .parse(normalizeRelations(target, data)) as never,
301
471
  );
302
472
  }),
303
473
  );
@@ -2,6 +2,7 @@ import { createHash, randomBytes } from "node:crypto";
2
2
  import Elysia from "elysia";
3
3
  import moment from "moment";
4
4
  import { eq, RecordId, Table } from "surrealdb";
5
+ import { getConfig } from "../core/config";
5
6
  import { escapeHtml, noStore, page } from "../core/html_page";
6
7
  import { makeModule } from "../core/module";
7
8
  import { publicBase } from "../core/public_url";
@@ -25,12 +26,19 @@ const CODE_TTL_MINUTES = 10;
25
26
  const UNUSED_CLIENT_TTL_HOURS = 24;
26
27
 
27
28
  // Dynamic registration is open to anyone who can reach the server, so the redirect URI is the
28
- // only thing stopping a rogue client from receiving an authorization code — only the hosted
29
- // Claude callbacks and loopback addresses (native/dev clients, RFC 8252) are accepted.
30
- const HOSTED_CALLBACKS = new Set([
29
+ // only thing stopping a rogue client from receiving an authorization code. Only these known
30
+ // callbacks, anything in config `mcp.redirectUris`, and loopback addresses (native/dev clients,
31
+ // RFC 8252 — Gemini CLI, VS Code and others use these) are accepted. An entry ending in "*" is a
32
+ // path prefix. Sources: each vendor's own docs (ChatGPT's callback carries a per-connector id).
33
+ const BUILT_IN_REDIRECTS = [
31
34
  "https://claude.ai/api/mcp/auth_callback",
32
35
  "https://claude.com/api/mcp/auth_callback",
33
- ]);
36
+ "https://chatgpt.com/connector_platform_oauth_redirect",
37
+ "https://chatgpt.com/connector/oauth/*",
38
+ "cursor://anysphere.cursor-mcp/oauth/callback",
39
+ "https://vscode.dev/redirect",
40
+ "https://insiders.vscode.dev/redirect",
41
+ ];
34
42
  const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]"]);
35
43
 
36
44
  const parseUrl = (value: string) => {
@@ -41,12 +49,31 @@ const parseUrl = (value: string) => {
41
49
  }
42
50
  };
43
51
 
52
+ // Compares parsed parts, never raw strings: `https://chatgpt.com@evil.com/...` and
53
+ // `https://chatgpt.com.evil.com/...` both start with an allowed string but are other hosts, and
54
+ // `..` segments are already resolved by URL parsing before the path is compared.
55
+ const matchesEntry = (url: URL, entry: string) => {
56
+ const wildcard = entry.endsWith("*");
57
+ const base = parseUrl(wildcard ? entry.slice(0, -1) : entry);
58
+ if (!base) return false;
59
+
60
+ if (url.protocol !== base.protocol || url.host !== base.host) return false;
61
+
62
+ return wildcard
63
+ ? url.pathname.startsWith(base.pathname)
64
+ : url.pathname === base.pathname && url.search === base.search;
65
+ };
66
+
44
67
  const isAllowedRedirectUri = (value: string) => {
45
68
  const url = parseUrl(value);
46
- if (!url || url.hash) return false;
47
- if (HOSTED_CALLBACKS.has(value)) return true;
69
+ if (!url || url.hash || url.username || url.password) return false;
48
70
 
49
- return url.protocol === "http:" && LOOPBACK_HOSTS.has(url.hostname);
71
+ if (url.protocol === "http:" && LOOPBACK_HOSTS.has(url.hostname)) return true;
72
+
73
+ const extra = getConfig().mcp?.redirectUris ?? [];
74
+ return [...BUILT_IN_REDIRECTS, ...extra].some((entry) =>
75
+ matchesEntry(url, entry),
76
+ );
50
77
  };
51
78
 
52
79
  // Exact match, except that a loopback client's ephemeral port is ignored (RFC 8252 §7.3) —
@@ -146,8 +173,10 @@ ${hidden("state", params.state)}
146
173
  <label for="password">Password</label>
147
174
  <input id="password" name="password" type="password" autocomplete="current-password" required>
148
175
  <div class="row">
149
- <button type="submit" name="action" value="deny" formnovalidate>Deny</button>
176
+ <!-- Allow must come first in the markup: pressing Enter in a field submits with the form's
177
+ first submit button, and that must never be Deny. CSS puts Deny back on the left. -->
150
178
  <button type="submit" name="action" value="approve" class="primary">Allow</button>
179
+ <button type="submit" name="action" value="deny" formnovalidate>Deny</button>
151
180
  </div>
152
181
  </form>`,
153
182
  status,
@@ -339,7 +368,7 @@ const oauth = makeModule(() => {
339
368
  ) {
340
369
  return oauthError(
341
370
  "invalid_redirect_uri",
342
- "redirect_uris must be the Claude callback or a loopback address.",
371
+ "redirect_uris must be a known client callback or a loopback address (extra callbacks go in config mcp.redirectUris).",
343
372
  );
344
373
  }
345
374