tina4-nodejs 3.13.138 → 3.13.140
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/CLAUDE.md +2 -2
- package/package.json +1 -1
- package/packages/cli/dist/bin.js +683 -329
- package/packages/cli/src/commands/generate.ts +7 -13
- package/packages/core/dist/index.js +688 -329
- package/packages/core/src/api.ts +33 -4
- package/packages/core/src/auth.ts +73 -6
- package/packages/core/src/authGate.ts +27 -20
- package/packages/core/src/graphql.ts +92 -0
- package/packages/core/src/index.ts +1 -0
- package/packages/core/src/job.ts +8 -1
- package/packages/core/src/mcp.ts +3 -1
- package/packages/core/src/middleware.ts +4 -2
- package/packages/core/src/push.ts +16 -0
- package/packages/core/src/queue.ts +15 -2
- package/packages/core/src/queueBackends/liteBackend.ts +15 -0
- package/packages/core/src/queueBackends/mongoBackend.ts +40 -6
- package/packages/core/src/server.ts +6 -1
- package/packages/core/src/sessionHandlers/databaseHandler.ts +60 -3
- package/packages/core/src/sso.ts +12 -4
- package/packages/core/src/ssoIdentity.ts +19 -0
- package/packages/core/src/ssrf.ts +221 -0
- package/packages/core/src/testClient.ts +3 -0
- package/packages/core/src/websocket.ts +4 -2
- package/packages/frond/dist/index.js +6 -0
- package/packages/frond/src/engine.ts +9 -0
- package/packages/orm/dist/index.js +675 -320
- package/packages/orm/src/adapters/firebird.ts +14 -45
- package/packages/orm/src/adapters/mssql.ts +21 -58
- package/packages/orm/src/adapters/mysql.ts +10 -49
- package/packages/orm/src/adapters/odbc.ts +11 -42
- package/packages/orm/src/adapters/postgres.ts +20 -60
- package/packages/orm/src/adapters/sqlCrud.ts +178 -0
- package/packages/orm/src/adapters/sqlite.ts +46 -66
- package/packages/orm/src/index.ts +1 -1
- package/types/core/src/api.d.ts +6 -0
- package/types/core/src/auth.d.ts +30 -0
- package/types/core/src/graphql.d.ts +25 -0
- package/types/core/src/index.d.ts +1 -0
- package/types/core/src/job.d.ts +1 -0
- package/types/core/src/push.d.ts +5 -0
- package/types/core/src/queue.d.ts +7 -1
- package/types/core/src/queueBackends/liteBackend.d.ts +9 -0
- package/types/core/src/queueBackends/mongoBackend.d.ts +8 -1
- package/types/core/src/sessionHandlers/databaseHandler.d.ts +25 -1
- package/types/core/src/sso.d.ts +1 -0
- package/types/core/src/ssoIdentity.d.ts +10 -0
- package/types/core/src/ssrf.d.ts +19 -0
- package/types/orm/src/adapters/sqlCrud.d.ts +90 -0
- package/types/orm/src/adapters/sqlite.d.ts +17 -0
- package/types/orm/src/index.d.ts +1 -1
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Copyright (c) 2026 Code Infinity
|
|
3
|
+
SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
This Source Code Form is subject to the terms of the Mozilla Public
|
|
5
|
+
License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
6
|
+
file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* One CRUD write composer for every SQL engine, instead of one per adapter.
|
|
11
|
+
*
|
|
12
|
+
* `sqlDialect.ts` already collapsed the SQL *fragment* builders (columns, the
|
|
13
|
+
* SET list, the WHERE list). What it did NOT collapse was the METHOD BODIES:
|
|
14
|
+
* every SQL adapter's `insert`/`update`/`delete` re-implemented the SAME
|
|
15
|
+
* envelope around those fragments — the string-filter branch vs the hash-filter
|
|
16
|
+
* branch, the `[...Object.values(data), ...(params ?? [])]` value assembly, the
|
|
17
|
+
* batch fan-out — differing only in the two things a `Dialect` already captures
|
|
18
|
+
* (identifier quoting, parameter marker) plus three per-engine knobs:
|
|
19
|
+
*
|
|
20
|
+
* INSERT SUFFIX "" | " RETURNING *" | "; SELECT SCOPE_IDENTITY() AS id"
|
|
21
|
+
* MARKER START 1 (PostgreSQL $N) | 0 (MSSQL @pN) | ignored (`?` engines)
|
|
22
|
+
* FRAGMENT REWRITE a raw "id = ?" WHERE string: identity on a `?` engine,
|
|
23
|
+
* convertPlaceholders/bindable on a positional one
|
|
24
|
+
*
|
|
25
|
+
* This is the Node port of the Python master's `SqlCrudMixin`
|
|
26
|
+
* (tina4_python/database/adapter.py): build the SQL and the ordered parameter
|
|
27
|
+
* list engine-neutrally from a dialect + those knobs, and hand the pair back to
|
|
28
|
+
* the adapter to EXECUTE.
|
|
29
|
+
*
|
|
30
|
+
* Like `sqlDialect.ts`, these functions build STRINGS and parameter arrays and
|
|
31
|
+
* NOTHING else. Execution and result extraction stay in the adapters on purpose:
|
|
32
|
+
* `this.db.prepare().run()` vs `await this.client!.query()` vs tedious vs a
|
|
33
|
+
* Firebird transaction handle, and RETURNING / SCOPE_IDENTITY() / lastId /
|
|
34
|
+
* affectedRows normalisation, are genuinely per-driver — folding them in here
|
|
35
|
+
* would trade a real duplication for a fake abstraction.
|
|
36
|
+
*
|
|
37
|
+
* MongoDB has no entry: it does not build SQL at all.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
import { type Dialect, buildInsert, buildSetClause, buildWhereClause } from "./sqlDialect.js";
|
|
41
|
+
|
|
42
|
+
/** A composed statement: the SQL text and its ordered parameter list. */
|
|
43
|
+
export interface CrudSql {
|
|
44
|
+
sql: string;
|
|
45
|
+
values: unknown[];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* A composed batch INSERT: one SQL statement plus one parameter row per input
|
|
50
|
+
* record, ready for the adapter's own `executeMany`.
|
|
51
|
+
*/
|
|
52
|
+
export interface CrudBatchSql {
|
|
53
|
+
sql: string;
|
|
54
|
+
paramsList: unknown[][];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The only per-engine differences in a composed write's SQL + parameter list. */
|
|
58
|
+
export interface WriteOptions {
|
|
59
|
+
/**
|
|
60
|
+
* 1-based position of the FIRST parameter marker. PostgreSQL numbers `$N`
|
|
61
|
+
* from 1; MSSQL names `@pN` from 0 and BINDS by that name, so the start must
|
|
62
|
+
* match its binding loop. Engines whose marker is `?` ignore it. Default 1.
|
|
63
|
+
*/
|
|
64
|
+
startAt?: number;
|
|
65
|
+
/**
|
|
66
|
+
* Rewrite a raw string WHERE fragment's `?` markers for this engine, given the
|
|
67
|
+
* 1-based position of the fragment's first marker. `?` engines leave it
|
|
68
|
+
* untouched (the default); a positional engine passes its `convertPlaceholders`
|
|
69
|
+
* / `bindable`. Only ever applied to the developer-supplied string filter,
|
|
70
|
+
* never to the framework-built hash WHERE.
|
|
71
|
+
*/
|
|
72
|
+
convertFragment?: (fragment: string, startAt: number) => string;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const identityFragment = (fragment: string): string => fragment;
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* A single-row INSERT: `INSERT INTO t (a, b) VALUES (?, ?)<suffix>` and the row's
|
|
79
|
+
* values in column order. `suffix` carries the genuinely engine-specific tail
|
|
80
|
+
* (PostgreSQL's ` RETURNING *`, MSSQL's SCOPE_IDENTITY() probe).
|
|
81
|
+
*/
|
|
82
|
+
export function buildInsertRow(
|
|
83
|
+
dialect: Dialect,
|
|
84
|
+
table: string,
|
|
85
|
+
data: Record<string, unknown>,
|
|
86
|
+
suffix = "",
|
|
87
|
+
options: WriteOptions = {},
|
|
88
|
+
): CrudSql {
|
|
89
|
+
const keys = Object.keys(data);
|
|
90
|
+
const sql = buildInsert(dialect, table, keys, suffix, options.startAt ?? 1);
|
|
91
|
+
return { sql, values: Object.values(data) };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* A batch INSERT from a list of rows — one parameterised statement the adapter
|
|
96
|
+
* runs once per row via its own `executeMany`. Returns `null` for an empty list
|
|
97
|
+
* (the adapters treat that as a no-op affecting zero rows). The keys come from
|
|
98
|
+
* the first row, exactly as every adapter already assumed.
|
|
99
|
+
*/
|
|
100
|
+
export function buildInsertRows(
|
|
101
|
+
dialect: Dialect,
|
|
102
|
+
table: string,
|
|
103
|
+
rows: Record<string, unknown>[],
|
|
104
|
+
): CrudBatchSql | null {
|
|
105
|
+
if (rows.length === 0) return null;
|
|
106
|
+
const keys = Object.keys(rows[0]);
|
|
107
|
+
const sql = buildInsert(dialect, table, keys);
|
|
108
|
+
const paramsList = rows.map((row) => keys.map((k) => row[k]));
|
|
109
|
+
return { sql, paramsList };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* An UPDATE from a data object and either a hash filter (`{id: 5}` →
|
|
114
|
+
* `WHERE "id" = ?`) or a raw string filter (`"id = ?"` + `params`). The SET
|
|
115
|
+
* values always lead the parameter list; the WHERE values follow.
|
|
116
|
+
*/
|
|
117
|
+
export function buildUpdate(
|
|
118
|
+
dialect: Dialect,
|
|
119
|
+
table: string,
|
|
120
|
+
data: Record<string, unknown>,
|
|
121
|
+
filter: Record<string, unknown> | string,
|
|
122
|
+
params?: unknown[],
|
|
123
|
+
options: WriteOptions = {},
|
|
124
|
+
): CrudSql {
|
|
125
|
+
const startAt = options.startAt ?? 1;
|
|
126
|
+
const convertFragment = options.convertFragment ?? identityFragment;
|
|
127
|
+
const dataKeys = Object.keys(data);
|
|
128
|
+
const setClauses = buildSetClause(dialect, dataKeys, startAt);
|
|
129
|
+
const whereStart = startAt + dataKeys.length;
|
|
130
|
+
|
|
131
|
+
if (typeof filter === "string") {
|
|
132
|
+
const where = filter ? ` WHERE ${convertFragment(filter, whereStart)}` : "";
|
|
133
|
+
return {
|
|
134
|
+
sql: `UPDATE ${dialect.quote(table)} SET ${setClauses}${where}`,
|
|
135
|
+
values: [...Object.values(data), ...(params ?? [])],
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const whereClauses = buildWhereClause(dialect, Object.keys(filter), whereStart);
|
|
140
|
+
return {
|
|
141
|
+
sql: `UPDATE ${dialect.quote(table)} SET ${setClauses} WHERE ${whereClauses}`,
|
|
142
|
+
values: [...Object.values(data), ...Object.values(filter)],
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* A DELETE from either a hash filter (`{id: 5}` → `WHERE "id" = ?`) or a raw
|
|
148
|
+
* string filter (`"age < ?"` + `params`). An empty string filter deletes the
|
|
149
|
+
* whole table (the explicit `truncate()` spelling routes through here).
|
|
150
|
+
*
|
|
151
|
+
* The array-of-dicts filter form is NOT handled here: only SQLite and ODBC
|
|
152
|
+
* accept it, and both loop it into per-row `delete` calls that land back in one
|
|
153
|
+
* of the two branches above — that fan-out stays in those two adapters.
|
|
154
|
+
*/
|
|
155
|
+
export function buildDelete(
|
|
156
|
+
dialect: Dialect,
|
|
157
|
+
table: string,
|
|
158
|
+
filter: Record<string, unknown> | string,
|
|
159
|
+
params?: unknown[],
|
|
160
|
+
options: WriteOptions = {},
|
|
161
|
+
): CrudSql {
|
|
162
|
+
const startAt = options.startAt ?? 1;
|
|
163
|
+
const convertFragment = options.convertFragment ?? identityFragment;
|
|
164
|
+
|
|
165
|
+
if (typeof filter === "string") {
|
|
166
|
+
const where = filter ? ` WHERE ${convertFragment(filter, startAt)}` : "";
|
|
167
|
+
return {
|
|
168
|
+
sql: `DELETE FROM ${dialect.quote(table)}${where}`,
|
|
169
|
+
values: params ?? [],
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const whereClauses = buildWhereClause(dialect, Object.keys(filter), startAt);
|
|
174
|
+
return {
|
|
175
|
+
sql: `DELETE FROM ${dialect.quote(table)} WHERE ${whereClauses}`,
|
|
176
|
+
values: Object.values(filter),
|
|
177
|
+
};
|
|
178
|
+
}
|
|
@@ -7,9 +7,10 @@ file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { DatabaseSync } from "node:sqlite";
|
|
10
|
-
import { ANSI_DIALECT
|
|
10
|
+
import { ANSI_DIALECT } from "./sqlDialect.js";
|
|
11
|
+
import { buildDelete, buildInsertRow, buildInsertRows, buildUpdate } from "./sqlCrud.js";
|
|
11
12
|
import { mkdirSync } from "node:fs";
|
|
12
|
-
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
13
|
+
import { dirname, isAbsolute, join, resolve, sep } from "node:path";
|
|
13
14
|
import type { DatabaseAdapter, DatabaseResult, ColumnInfo, FieldDefinition } from "../types.js";
|
|
14
15
|
import { SQLTranslator } from "../sqlTranslator.js";
|
|
15
16
|
|
|
@@ -76,31 +77,45 @@ function sqliteVersionAtLeast(major: number, minor: number, patch: number): bool
|
|
|
76
77
|
*
|
|
77
78
|
* Matches the tina4-python + tina4-php convention:
|
|
78
79
|
* ":memory:" → passthrough
|
|
79
|
-
* "data/app.db" → {cwd}/data/app.db (auto-
|
|
80
|
+
* "data/app.db" → {cwd}/data/app.db (parent auto-created, mode 0775)
|
|
80
81
|
* "/abs/app.db" → /abs/app.db (NO auto-mkdir; user's responsibility)
|
|
81
|
-
* "C:/Users/app.db" → C:/Users/app.db (
|
|
82
|
+
* "C:/Users/app.db" → C:/Users/app.db (recognised as absolute on EVERY OS)
|
|
83
|
+
* "../../etc/foo.db" → REFUSED loudly (its parent escapes cwd)
|
|
82
84
|
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
85
|
+
* The canonical contract is ADR-0086. Two things this pins that node:path alone
|
|
86
|
+
* gets wrong: a drive-letter path is absolute on POSIX too (isAbsolute does not
|
|
87
|
+
* recognise it, so it would be re-rooted under cwd), and a relative path whose
|
|
88
|
+
* parent escapes cwd must be REFUSED — never the silent mkdir outside the
|
|
89
|
+
* project that was the root cause of the `EROFS: mkdir '/data'` crash.
|
|
86
90
|
*/
|
|
87
|
-
function resolveSqlitePath(dbPath: string): string {
|
|
91
|
+
export function resolveSqlitePath(dbPath: string): string {
|
|
88
92
|
if (dbPath === ":memory:") return dbPath;
|
|
89
93
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
const cwd = resolve(process.cwd());
|
|
98
|
-
const abs = resolve(path);
|
|
99
|
-
if (abs.startsWith(cwd + "/") || abs === cwd) {
|
|
100
|
-
mkdirSync(dirname(abs), { recursive: true });
|
|
101
|
-
}
|
|
102
|
-
// Otherwise, trust the user — don't touch the filesystem.
|
|
94
|
+
// A Windows drive-letter path (C:/... or C:\...) is absolute on EVERY OS.
|
|
95
|
+
// node:path.isAbsolute does NOT recognise it on POSIX (it would re-root the
|
|
96
|
+
// path under cwd), so recognise it explicitly for parity (ADR-0086).
|
|
97
|
+
const isWindowsAbs = /^[A-Za-z]:[\\/]/.test(dbPath);
|
|
98
|
+
if (isAbsolute(dbPath) || isWindowsAbs) {
|
|
99
|
+
// Absolute — trust the user; never auto-mkdir (parity with the other three).
|
|
100
|
+
return dbPath;
|
|
103
101
|
}
|
|
102
|
+
|
|
103
|
+
// Relative — resolve under cwd; create the parent (mode 0775) ONLY when it
|
|
104
|
+
// stays inside cwd. A relative path whose parent escapes the project is
|
|
105
|
+
// REFUSED loudly — never a silent mkdir outside the project.
|
|
106
|
+
const cwd = resolve(process.cwd());
|
|
107
|
+
const path = join(cwd, dbPath);
|
|
108
|
+
const parent = dirname(path);
|
|
109
|
+
const absParent = resolve(parent);
|
|
110
|
+
if (absParent !== cwd && !absParent.startsWith(cwd + sep)) {
|
|
111
|
+
throw new Error(
|
|
112
|
+
`SQLite path "${dbPath}" resolves outside the project directory: ` +
|
|
113
|
+
`"${absParent}" is not within "${cwd}". Tina4 refuses to create ` +
|
|
114
|
+
`directories outside the project (ADR-0086). Use an absolute path for a ` +
|
|
115
|
+
`database that lives outside the project.`,
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
mkdirSync(parent, { recursive: true, mode: 0o775 });
|
|
104
119
|
return path;
|
|
105
120
|
}
|
|
106
121
|
|
|
@@ -264,17 +279,13 @@ export class SQLiteAdapter implements DatabaseAdapter {
|
|
|
264
279
|
|
|
265
280
|
insert(table: string, data: Record<string, unknown> | Record<string, unknown>[]): DatabaseResult {
|
|
266
281
|
if (Array.isArray(data)) {
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
const
|
|
270
|
-
const paramsList = data.map((row) => keys.map((k) => row[k]));
|
|
271
|
-
const result = this.executeMany(sql, paramsList);
|
|
282
|
+
const batch = buildInsertRows(ANSI_DIALECT, table, data);
|
|
283
|
+
if (!batch) return { success: true, affectedRows: 0 };
|
|
284
|
+
const result = this.executeMany(batch.sql, batch.paramsList);
|
|
272
285
|
return { success: true, affectedRows: result.affectedRows, lastId: result.lastId };
|
|
273
286
|
}
|
|
274
287
|
|
|
275
|
-
const
|
|
276
|
-
const sql = buildInsert(ANSI_DIALECT, table, keys);
|
|
277
|
-
const values = Object.values(data);
|
|
288
|
+
const { sql, values } = buildInsertRow(ANSI_DIALECT, table, data);
|
|
278
289
|
|
|
279
290
|
try {
|
|
280
291
|
const result = this.db.prepare(sql).run(...toSqlParams(values));
|
|
@@ -286,30 +297,11 @@ export class SQLiteAdapter implements DatabaseAdapter {
|
|
|
286
297
|
}
|
|
287
298
|
|
|
288
299
|
update(table: string, data: Record<string, unknown>, filter: Record<string, unknown> | string, params?: unknown[]): DatabaseResult {
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
// A raw WHERE fragment
|
|
292
|
-
//
|
|
293
|
-
|
|
294
|
-
// ["0","1",...], producing `WHERE "0" = ? AND "1" = ?` and SQLite reports
|
|
295
|
-
// `no such column: "0"`. delete() below already carried this branch and
|
|
296
|
-
// update() did not — the same gap 3.13.94 closed in the postgres/mysql/
|
|
297
|
-
// mssql/firebird adapters, still open here on the DEFAULT engine.
|
|
298
|
-
if (typeof filter === "string") {
|
|
299
|
-
const where = filter ? ` WHERE ${filter}` : "";
|
|
300
|
-
const sql = `UPDATE ${ANSI_DIALECT.quote(table)} SET ${setClauses}${where}`;
|
|
301
|
-
const values = [...Object.values(data), ...(params ?? [])];
|
|
302
|
-
try {
|
|
303
|
-
const result = this.db.prepare(sql).run(...toSqlParams(values));
|
|
304
|
-
return { success: true, affectedRows: Number(result.changes) };
|
|
305
|
-
} catch (e) {
|
|
306
|
-
return { success: false, affectedRows: 0, error: (e as Error).message };
|
|
307
|
-
}
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
const whereClauses = buildWhereClause(ANSI_DIALECT, Object.keys(filter));
|
|
311
|
-
const sql = `UPDATE ${ANSI_DIALECT.quote(table)} SET ${setClauses} WHERE ${whereClauses}`;
|
|
312
|
-
const values = [...Object.values(data), ...Object.values(filter)];
|
|
300
|
+
// The string-filter vs hash-filter branch and the value assembly live in the
|
|
301
|
+
// shared composer (sqlCrud.ts); SQLite supplies only its driver execution.
|
|
302
|
+
// A raw WHERE fragment carries `?` markers, which SQLite uses natively, so no
|
|
303
|
+
// fragment rewrite is needed.
|
|
304
|
+
const { sql, values } = buildUpdate(ANSI_DIALECT, table, data, filter, params);
|
|
313
305
|
|
|
314
306
|
try {
|
|
315
307
|
const result = this.db.prepare(sql).run(...toSqlParams(values));
|
|
@@ -329,19 +321,7 @@ export class SQLiteAdapter implements DatabaseAdapter {
|
|
|
329
321
|
return { success: true, affectedRows: totalAffected };
|
|
330
322
|
}
|
|
331
323
|
|
|
332
|
-
|
|
333
|
-
const sql = filter ? `DELETE FROM "${table}" WHERE ${filter}` : `DELETE FROM "${table}"`;
|
|
334
|
-
try {
|
|
335
|
-
const result = this.db.prepare(sql).run(...toSqlParams(params ?? []));
|
|
336
|
-
return { success: true, affectedRows: Number(result.changes) };
|
|
337
|
-
} catch (e) {
|
|
338
|
-
return { success: false, affectedRows: 0, error: (e as Error).message };
|
|
339
|
-
}
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
const whereClauses = buildWhereClause(ANSI_DIALECT, Object.keys(filter));
|
|
343
|
-
const sql = `DELETE FROM ${ANSI_DIALECT.quote(table)} WHERE ${whereClauses}`;
|
|
344
|
-
const values = Object.values(filter);
|
|
324
|
+
const { sql, values } = buildDelete(ANSI_DIALECT, table, filter, params);
|
|
345
325
|
|
|
346
326
|
try {
|
|
347
327
|
const result = this.db.prepare(sql).run(...toSqlParams(values));
|
|
@@ -93,7 +93,7 @@ export type {
|
|
|
93
93
|
} from "./docstore.js";
|
|
94
94
|
|
|
95
95
|
// Database adapters
|
|
96
|
-
export { SQLiteAdapter } from "./adapters/sqlite.js";
|
|
96
|
+
export { SQLiteAdapter, resolveSqlitePath } from "./adapters/sqlite.js";
|
|
97
97
|
export { PostgresAdapter } from "./adapters/postgres.js";
|
|
98
98
|
export type { PostgresConfig } from "./adapters/postgres.js";
|
|
99
99
|
export { MysqlAdapter } from "./adapters/mysql.js";
|
package/types/core/src/api.d.ts
CHANGED
|
@@ -121,6 +121,11 @@ export interface ApiOptions {
|
|
|
121
121
|
* persisted; scoped to this instance.
|
|
122
122
|
*/
|
|
123
123
|
cookies?: boolean;
|
|
124
|
+
/**
|
|
125
|
+
* SSRF guard (ADR-0084): an explicit allow-list of hosts / host:port / CIDRs
|
|
126
|
+
* that bypass the private-address refusal even with the opt-out off.
|
|
127
|
+
*/
|
|
128
|
+
allowHosts?: string[];
|
|
124
129
|
}
|
|
125
130
|
/**
|
|
126
131
|
* Split an async byte iterable into UTF-8 lines. Handles LF and CRLF; a
|
|
@@ -155,6 +160,7 @@ export declare class Api {
|
|
|
155
160
|
private transportFn?;
|
|
156
161
|
private cookiesEnabled;
|
|
157
162
|
private cookies;
|
|
163
|
+
private allowHosts;
|
|
158
164
|
/**
|
|
159
165
|
* Construct an Api client.
|
|
160
166
|
*
|
package/types/core/src/auth.d.ts
CHANGED
|
@@ -1,4 +1,34 @@
|
|
|
1
1
|
import type { Middleware } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Token purposes that are never an identity (ADR-0079 s1). Frond's formToken()
|
|
4
|
+
* signs a JWT with the same TINA4_SECRET as an auth token, marked
|
|
5
|
+
* `type: "form"`. A form token proves where a write came from, never who the
|
|
6
|
+
* caller is, so every identity gate refuses these purposes. validToken itself
|
|
7
|
+
* stays purpose-neutral: the CSRF middleware needs it for form tokens.
|
|
8
|
+
*/
|
|
9
|
+
export declare const NON_IDENTITY_TOKEN_TYPES: readonly string[];
|
|
10
|
+
/**
|
|
11
|
+
* Minimum HMAC key length in bytes (ADR-0079 s2): the HS256 output size, RFC
|
|
12
|
+
* 7518 s3.2. A blank key is the shortest case - anyone can reproduce it.
|
|
13
|
+
*/
|
|
14
|
+
export declare const MIN_SECRET_BYTES = 32;
|
|
15
|
+
/**
|
|
16
|
+
* True when a VERIFIED payload may stand for a caller's identity. A payload
|
|
17
|
+
* whose `type` is a reserved non-identity purpose (a Frond form token) is
|
|
18
|
+
* refused; application payloads are otherwise untouched.
|
|
19
|
+
*/
|
|
20
|
+
export declare function isIdentityPayload(payload: Record<string, unknown> | null | undefined): payload is Record<string, unknown>;
|
|
21
|
+
/** The actionable error for a weak HMAC key, or null when the key is usable. */
|
|
22
|
+
export declare function insecureSecretMessage(secret: string | undefined | null): string | null;
|
|
23
|
+
/**
|
|
24
|
+
* Refuse to boot with a secret that makes tokens forgeable (ADR-0079 s2).
|
|
25
|
+
* Outside dev a blank TINA4_SECRET is refused; in any mode a set-but-short one
|
|
26
|
+
* is refused. Dev with a blank secret has already had one minted into
|
|
27
|
+
* .env.local by ensureDevSecret(). RS256 keys are PEMs and are not measured.
|
|
28
|
+
*
|
|
29
|
+
* @throws Error with the actionable message naming TINA4_SECRET.
|
|
30
|
+
*/
|
|
31
|
+
export declare function requireBootSecret(): void;
|
|
2
32
|
/**
|
|
3
33
|
* Ensure a usable TINA4_SECRET exists. Run ONCE at server boot, after env load
|
|
4
34
|
* and before auth is used. Mirrors Python's `ensure_dev_secret()`.
|
|
@@ -38,6 +38,16 @@ export declare function graphqlAutoSchemaEnabled(): boolean;
|
|
|
38
38
|
* set `<= 0` to disable the guard. A non-numeric value falls back to 50.
|
|
39
39
|
*/
|
|
40
40
|
export declare function graphqlMaxDepth(): number;
|
|
41
|
+
/**
|
|
42
|
+
* F2: maximum number of expanded selection nodes a single query may request.
|
|
43
|
+
* The depth guard bounds NESTING but not WIDTH: a fragment that spreads another
|
|
44
|
+
* fragment many times (a "fragment bomb") stays shallow while expanding to
|
|
45
|
+
* millions of fields, and a query with thousands of aliases of one field is not
|
|
46
|
+
* deep at all. This budget bounds the expanded selection tree (fragments
|
|
47
|
+
* expanded, aliases counted) so both are rejected before any resolver runs.
|
|
48
|
+
* `TINA4_GRAPHQL_MAX_NODES` overrides the default (1000); `<= 0` disables it.
|
|
49
|
+
*/
|
|
50
|
+
export declare function graphqlMaxNodes(): number;
|
|
41
51
|
export declare class GraphQL {
|
|
42
52
|
private types;
|
|
43
53
|
private queries;
|
|
@@ -53,6 +63,12 @@ export declare class GraphQL {
|
|
|
53
63
|
* spread / inline fragment, so circular fragments are caught too.
|
|
54
64
|
*/
|
|
55
65
|
maxDepth: number;
|
|
66
|
+
/**
|
|
67
|
+
* Maximum expanded selection nodes per query (from `TINA4_GRAPHQL_MAX_NODES`,
|
|
68
|
+
* default 1000; `<= 0` disables). Bounds query WIDTH — fragment bombs and
|
|
69
|
+
* alias explosions — which the depth guard does not. See {@link graphqlMaxNodes}.
|
|
70
|
+
*/
|
|
71
|
+
maxNodes: number;
|
|
56
72
|
/**
|
|
57
73
|
* Decorator-style resolver registration.
|
|
58
74
|
*
|
|
@@ -124,6 +140,15 @@ export declare class GraphQL {
|
|
|
124
140
|
* instead of recursing until the interpreter stack overflows. Top-level
|
|
125
141
|
* starts at depth 1; `maxDepth <= 0` disables the guard.
|
|
126
142
|
*/
|
|
143
|
+
/**
|
|
144
|
+
* Count the expanded selection nodes, throwing {@link ComplexityError} past
|
|
145
|
+
* `maxNodes` (F2). Fragment spreads are expanded (so a fragment bomb is
|
|
146
|
+
* counted, not the small unexpanded document) and every field — including
|
|
147
|
+
* each alias — counts once. The count is capped by `maxNodes` and the walk by
|
|
148
|
+
* `maxDepth`, so a circular fragment trips the budget instead of looping and
|
|
149
|
+
* the check itself can never cost more than the budget.
|
|
150
|
+
*/
|
|
151
|
+
private assertComplexity;
|
|
127
152
|
private resolveSelectionsInto;
|
|
128
153
|
/**
|
|
129
154
|
* Generate SDL schema string.
|
|
@@ -40,6 +40,7 @@ export type { ServiceOptions, ServiceContext, ServiceHandler, ServiceInfo } from
|
|
|
40
40
|
export { responseCache, clearCache, cacheStats, cacheGet, cacheSet, cacheDelete, cacheClear, cacheBackendStats, sweep, createBackend, _resetBackend } from "./cache.js";
|
|
41
41
|
export type { ResponseCacheConfig, CacheBackend } from "./cache.js";
|
|
42
42
|
export { Api, ApiStreamError, parseLineStream, parseSseStream } from "./api.js";
|
|
43
|
+
export { guardUrl, isBlockedAddress, allowPrivateRequests, SsrfError, ALLOW_PRIVATE_ENV } from "./ssrf.js";
|
|
43
44
|
export type { ApiResult, ApiOptions, ApiTransport, DownloadResult, UploadOptions, StreamOptions, SseEvent } from "./api.js";
|
|
44
45
|
export { Context, defaultContext, existingContext, fts5Supported, _sharedContexts } from "./context/index.js";
|
|
45
46
|
export type { SearchHit } from "./context/index.js";
|
package/types/core/src/job.d.ts
CHANGED
|
@@ -31,6 +31,7 @@ export interface JobLifecycle {
|
|
|
31
31
|
export type QueueJob = JobData & JobLifecycle;
|
|
32
32
|
export interface JobQueueBridge {
|
|
33
33
|
_failJob(topic: string, job: QueueJob, reason: string, maxRetries: number): void;
|
|
34
|
+
_rejectJob(topic: string, job: QueueJob, reason: string, maxRetries: number): void;
|
|
34
35
|
_retryJob(topic: string, job: QueueJob, delaySeconds?: number): void;
|
|
35
36
|
_completeJob(topic: string, job: QueueJob): void;
|
|
36
37
|
getMaxRetries(): number;
|
package/types/core/src/push.d.ts
CHANGED
|
@@ -11,6 +11,11 @@ export interface PushOptions {
|
|
|
11
11
|
privateKey?: string;
|
|
12
12
|
ttl?: number;
|
|
13
13
|
urgency?: "very-low" | "low" | "normal" | "high";
|
|
14
|
+
/**
|
|
15
|
+
* SSRF guard (ADR-0084): an explicit allow-list of hosts / host:port / CIDRs
|
|
16
|
+
* that bypass the private-endpoint refusal even with the opt-out off.
|
|
17
|
+
*/
|
|
18
|
+
allowHosts?: string[];
|
|
14
19
|
}
|
|
15
20
|
export interface PushResult {
|
|
16
21
|
ok: boolean;
|
|
@@ -59,7 +59,7 @@ export interface ConsumeOptions {
|
|
|
59
59
|
export interface QueueBackendInterface {
|
|
60
60
|
push(queue: string, payload: unknown, delay?: number, priority?: number): string;
|
|
61
61
|
pop(queue: string): QueueJob | null;
|
|
62
|
-
size(queue: string): number;
|
|
62
|
+
size(queue: string, status?: string): number;
|
|
63
63
|
clear(queue: string): void;
|
|
64
64
|
/**
|
|
65
65
|
* Release whatever connection the backend holds, and be safe to call twice.
|
|
@@ -74,6 +74,7 @@ export interface QueueBackendInterface {
|
|
|
74
74
|
close(): void;
|
|
75
75
|
complete?(queue: string, id: string): void;
|
|
76
76
|
fail?(queue: string, id: string, error: string, maxRetries: number, retryBackoff: number): void;
|
|
77
|
+
reject?(queue: string, id: string, reason: string, maxRetries: number): void;
|
|
77
78
|
/**
|
|
78
79
|
* Explicit manual re-queue: returns true if the id was found and revived,
|
|
79
80
|
* false otherwise (parity with Python's backend.retry_job()). A backend may
|
|
@@ -289,6 +290,11 @@ export declare class Queue {
|
|
|
289
290
|
* after retryBackoff seconds) or dead-letter (attempts >= maxRetries).
|
|
290
291
|
*/
|
|
291
292
|
_failJob(queue: string, job: QueueJob, error: string, maxRetries: number): void;
|
|
293
|
+
/**
|
|
294
|
+
* Dead-letter a job immediately — no retry (ADR-0023 reject()). Distinct from
|
|
295
|
+
* _failJob, which retries until maxRetries is spent.
|
|
296
|
+
*/
|
|
297
|
+
_rejectJob(queue: string, job: QueueJob, reason: string, maxRetries: number): void;
|
|
292
298
|
/**
|
|
293
299
|
* Re-queue a job back to the main queue directory with incremented attempts.
|
|
294
300
|
*/
|
|
@@ -130,5 +130,14 @@ export declare class LiteBackend {
|
|
|
130
130
|
* dead-letter file -- two spellings of the same intent that previously
|
|
131
131
|
* diverged.
|
|
132
132
|
*/
|
|
133
|
+
/**
|
|
134
|
+
* Reject a job permanently — dead-letter it NOW, no retry (ADR-0023).
|
|
135
|
+
*
|
|
136
|
+
* Unlike failJob(), which requeues until maxRetries is spent, rejectJob()
|
|
137
|
+
* moves a known-poison job straight to the dead-letter store. attempts is
|
|
138
|
+
* floored at maxRetries so deadLetters() (attempts >= maxRetries) returns it
|
|
139
|
+
* and size("dead") agrees.
|
|
140
|
+
*/
|
|
141
|
+
rejectJob(queue: string, job: QueueJob, reason: string, maxRetries: number): void;
|
|
133
142
|
retryJob(queue: string, job: QueueJob, delaySeconds?: number): void;
|
|
134
143
|
}
|
|
@@ -76,7 +76,14 @@ export declare class MongoBackend implements QueueBackend {
|
|
|
76
76
|
popById(queue: string, id: string): QueueJob | null;
|
|
77
77
|
push(queue: string, payload: unknown, delay?: number, priority?: number): string;
|
|
78
78
|
pop(queue: string): QueueJob | null;
|
|
79
|
-
size(queue: string): number;
|
|
79
|
+
size(queue: string, status?: string): number;
|
|
80
|
+
/**
|
|
81
|
+
* Dead-letter a job immediately — no retry (ADR-0023 reject()). Distinct from
|
|
82
|
+
* fail(): fail() requeues until maxRetries is spent. attempts is floored at
|
|
83
|
+
* maxRetries so deadLetters()/size("dead") (which filter/scope to the
|
|
84
|
+
* dead-letter store) see it.
|
|
85
|
+
*/
|
|
86
|
+
reject(queue: string, id: string, reason: string, maxRetries: number): void;
|
|
80
87
|
clear(queue: string): void;
|
|
81
88
|
/**
|
|
82
89
|
* Acknowledge a completed job — drop its reservation so the reclaim never
|
|
@@ -86,9 +86,33 @@ export declare class DatabaseSessionHandler implements SessionHandler {
|
|
|
86
86
|
/** Run a statement that returns nothing. */
|
|
87
87
|
private exec;
|
|
88
88
|
/**
|
|
89
|
-
* Ensure the session table exists (called once on first use)
|
|
89
|
+
* Ensure the session table exists (called once on first use) - safely when
|
|
90
|
+
* several processes race to create it.
|
|
91
|
+
*
|
|
92
|
+
* Every per-engine statement above is check-then-act, so two processes can
|
|
93
|
+
* both find the table absent and both CREATE; the loser errors, and every
|
|
94
|
+
* engine spells that differently (a pg_type unique violation on PostgreSQL,
|
|
95
|
+
* Msg 2714 on SQL Server, an RDB$RELATIONS unique violation on Firebird). So
|
|
96
|
+
* a failure is judged by RE-CHECKING whether the table now exists, never by
|
|
97
|
+
* parsing the message - the rescue the Firebird note above has always
|
|
98
|
+
* assumed, and which was not here. MEASURED on the lab before it: six real
|
|
99
|
+
* processes racing lost one to five of them on PostgreSQL, SQLite and
|
|
100
|
+
* Firebird.
|
|
101
|
+
*
|
|
102
|
+
* The re-check is RETRIED because the loser can fail BEFORE the winner has
|
|
103
|
+
* committed. On MySQL, CREATE TABLE IF NOT EXISTS takes a shared metadata
|
|
104
|
+
* lock, checks, then upgrades it to exclusive; two upgrades deadlock, and when
|
|
105
|
+
* MySQL cannot back the victim off silently it answers 1213 "Deadlock found"
|
|
106
|
+
* while the winner's table is still invisible (tina4-php CI run 35972320442).
|
|
107
|
+
* Bounded, so a genuine failure (no CREATE grant) still throws. Same attempts
|
|
108
|
+
* and delays in all four frameworks.
|
|
90
109
|
*/
|
|
91
110
|
private ensureTable;
|
|
111
|
+
/**
|
|
112
|
+
* Whether tina4_session exists, asked the one way every engine answers alike:
|
|
113
|
+
* a query against it that returns no rows succeeds only if it is there.
|
|
114
|
+
*/
|
|
115
|
+
private tableExists;
|
|
92
116
|
read(sessionId: string): SessionData | null;
|
|
93
117
|
write(sessionId: string, data: SessionData, ttl: number): void;
|
|
94
118
|
destroy(sessionId: string): void;
|
package/types/core/src/sso.d.ts
CHANGED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The stored SSO identity, only while it is live (ADR-0079 s5). `stored` is the
|
|
3
|
+
* reserved _tina4_sso session value. The identity needs an issuer and a
|
|
4
|
+
* subject, and a numeric expires_at that is 0 (the provider gave no lifetime)
|
|
5
|
+
* or still in the future.
|
|
6
|
+
*
|
|
7
|
+
* Its own dependency-free module, so the auth gate can use it without
|
|
8
|
+
* importing the OIDC client (and everything the client imports).
|
|
9
|
+
*/
|
|
10
|
+
export declare function liveSessionIdentity(stored: unknown): Record<string, any> | null;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export declare const ALLOW_PRIVATE_ENV = "TINA4_ALLOW_PRIVATE_REQUESTS";
|
|
2
|
+
/** Raised when the SSRF guard refuses an outbound request. */
|
|
3
|
+
export declare class SsrfError extends Error {
|
|
4
|
+
constructor(message: string);
|
|
5
|
+
}
|
|
6
|
+
/** True when TINA4_ALLOW_PRIVATE_REQUESTS opts out of the guard. */
|
|
7
|
+
export declare function allowPrivateRequests(): boolean;
|
|
8
|
+
/**
|
|
9
|
+
* Classify one IP string: true = private/internal, refuse it. An IPv4-mapped
|
|
10
|
+
* IPv6 address is classified by its embedded IPv4. An unparseable value is
|
|
11
|
+
* treated as blocked - the guard refuses what it cannot classify.
|
|
12
|
+
*/
|
|
13
|
+
export declare function isBlockedAddress(ip: string): boolean;
|
|
14
|
+
/**
|
|
15
|
+
* Refuse `url` when it targets a private/internal address. Throws SsrfError for a
|
|
16
|
+
* non-http(s) scheme, an unresolvable host, or any resolved address in the
|
|
17
|
+
* blocked space. `allowHosts` is a list of hosts / host:port / CIDRs to permit.
|
|
18
|
+
*/
|
|
19
|
+
export declare function guardUrl(url: string, allowHosts?: string[]): Promise<void>;
|