tina4-nodejs 3.13.92 → 3.13.95
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 +170 -28
- package/README.md +2 -2
- package/package.json +13 -9
- package/packages/cli/dist/bin.js +33126 -30055
- package/packages/cli/src/commands/metrics.ts +17 -11
- package/packages/cli/src/commands/serve.ts +10 -9
- package/packages/core/dist/index.js +33062 -29908
- package/packages/core/src/ai.ts +7 -1
- package/packages/core/src/auth.ts +191 -39
- package/packages/core/src/background.ts +19 -19
- package/packages/core/src/cache.ts +492 -49
- package/packages/core/src/devAdmin.ts +79 -32
- package/packages/core/src/devMailbox.ts +20 -44
- package/packages/core/src/dispatchPipeline.ts +285 -0
- package/packages/core/src/dotenv.ts +185 -40
- package/packages/core/src/index.ts +7 -6
- package/packages/core/src/logger.ts +257 -36
- package/packages/core/src/mcp.ts +1 -1
- package/packages/core/src/messenger.ts +81 -13
- package/packages/core/src/metrics.ts +199 -961
- package/packages/core/src/middleware.ts +390 -123
- package/packages/core/src/queue.ts +188 -32
- package/packages/core/src/queueBackends/kafkaBackend.ts +109 -13
- package/packages/core/src/queueBackends/liteBackend.ts +13 -0
- package/packages/core/src/queueBackends/mongoBackend.ts +101 -9
- package/packages/core/src/queueBackends/rabbitmqBackend.ts +22 -4
- package/packages/core/src/rateLimiter.ts +10 -5
- package/packages/core/src/request.ts +6 -9
- package/packages/core/src/response.ts +46 -1
- package/packages/core/src/router.ts +29 -4
- package/packages/core/src/server.ts +751 -414
- package/packages/core/src/session.ts +244 -27
- package/packages/core/src/sessionHandlers/childError.ts +72 -0
- package/packages/core/src/sessionHandlers/databaseHandler.ts +338 -48
- package/packages/core/src/sessionHandlers/memcachedHandler.ts +181 -0
- package/packages/core/src/sessionHandlers/mongoClient.ts +293 -202
- package/packages/core/src/sessionHandlers/mongoHandler.ts +88 -8
- package/packages/core/src/sessionHandlers/respClient.ts +16 -143
- package/packages/core/src/sessionHandlers/sqlClient.ts +290 -0
- package/packages/core/src/sessionHandlers/syncBridge.ts +190 -0
- package/packages/core/src/sessionHandlers/syncSocket.ts +236 -0
- package/packages/core/src/testClient.ts +18 -5
- package/packages/core/src/trustedProxy.ts +249 -0
- package/packages/core/src/types.ts +29 -5
- package/packages/core/src/websocket.ts +66 -0
- package/packages/frond/dist/index.js +74 -31
- package/packages/frond/src/engine.ts +99 -33
- package/packages/orm/dist/index.js +26554 -23400
- package/packages/orm/src/adapters/firebird.ts +183 -56
- package/packages/orm/src/adapters/mongodb.ts +25 -4
- package/packages/orm/src/adapters/mssql.ts +114 -29
- package/packages/orm/src/adapters/mysql.ts +103 -40
- package/packages/orm/src/adapters/odbc.ts +44 -21
- package/packages/orm/src/adapters/postgres.ts +118 -26
- package/packages/orm/src/adapters/sqlDialect.ts +120 -0
- package/packages/orm/src/adapters/sqlite.ts +64 -25
- package/packages/orm/src/baseModel.ts +135 -40
- package/packages/orm/src/cachedDatabase.ts +43 -19
- package/packages/orm/src/connectTimeout.ts +265 -0
- package/packages/orm/src/database.ts +338 -198
- package/packages/orm/src/databaseResult.ts +65 -13
- package/packages/orm/src/databaseUrl.ts +484 -0
- package/packages/orm/src/docstore.ts +386 -145
- package/packages/orm/src/index.ts +13 -3
- package/packages/orm/src/migration.ts +18 -3
- package/packages/orm/src/queryBuilder.ts +38 -4
- package/packages/orm/src/sqlTranslator.ts +310 -4
- package/packages/orm/src/types.ts +15 -4
- package/types/cli/src/bin.d.ts +92 -0
- package/types/cli/src/commands/build.d.ts +2 -0
- package/types/cli/src/commands/generate.d.ts +47 -0
- package/types/cli/src/commands/init.d.ts +1 -0
- package/types/cli/src/commands/metrics.d.ts +6 -0
- package/types/cli/src/commands/migrate.d.ts +1 -0
- package/types/cli/src/commands/migrateCreate.d.ts +1 -0
- package/types/cli/src/commands/migrateRollback.d.ts +1 -0
- package/types/cli/src/commands/migrateStatus.d.ts +1 -0
- package/types/cli/src/commands/queue.d.ts +20 -0
- package/types/cli/src/commands/routes.d.ts +1 -0
- package/types/cli/src/commands/seed.d.ts +1 -0
- package/types/cli/src/commands/serve.d.ts +6 -0
- package/types/cli/src/commands/test.d.ts +1 -0
- package/types/core/src/ai.d.ts +64 -0
- package/types/core/src/api.d.ts +262 -0
- package/types/core/src/auth.d.ts +177 -0
- package/types/core/src/authGate.d.ts +20 -0
- package/types/core/src/background.d.ts +34 -0
- package/types/core/src/cache.d.ts +163 -0
- package/types/core/src/constants.d.ts +38 -0
- package/types/core/src/container.d.ts +44 -0
- package/types/core/src/context/chunker.d.ts +31 -0
- package/types/core/src/context/index.d.ts +93 -0
- package/types/core/src/devAdmin.d.ts +179 -0
- package/types/core/src/devMailbox.d.ts +54 -0
- package/types/core/src/dispatchPipeline.d.ts +117 -0
- package/types/core/src/docs.d.ts +141 -0
- package/types/core/src/docsAutoDiscovery.d.ts +6 -0
- package/types/core/src/dotenv.d.ts +87 -0
- package/types/core/src/env.d.ts +28 -0
- package/types/core/src/errorOverlay.d.ts +36 -0
- package/types/core/src/events.d.ts +75 -0
- package/types/core/src/fakeData.d.ts +55 -0
- package/types/core/src/feedback.d.ts +90 -0
- package/types/core/src/graphql.d.ts +207 -0
- package/types/core/src/health.d.ts +22 -0
- package/types/core/src/htmlElement.d.ts +75 -0
- package/types/core/src/i18n.d.ts +37 -0
- package/types/core/src/index.d.ts +92 -0
- package/types/core/src/job.d.ts +39 -0
- package/types/core/src/logger.d.ts +200 -0
- package/types/core/src/mcp.d.ts +248 -0
- package/types/core/src/messenger.d.ts +191 -0
- package/types/core/src/metrics.d.ts +41 -0
- package/types/core/src/middleware.d.ts +330 -0
- package/types/core/src/mqtt.d.ts +257 -0
- package/types/core/src/mqttMessage.d.ts +67 -0
- package/types/core/src/plan.d.ts +96 -0
- package/types/core/src/projectIndex.d.ts +56 -0
- package/types/core/src/queue.d.ts +268 -0
- package/types/core/src/queueBackends/kafkaBackend.d.ts +117 -0
- package/types/core/src/queueBackends/liteBackend.d.ts +128 -0
- package/types/core/src/queueBackends/mongoBackend.d.ts +119 -0
- package/types/core/src/queueBackends/rabbitmqBackend.d.ts +55 -0
- package/types/core/src/rateLimiter.d.ts +49 -0
- package/types/core/src/request.d.ts +25 -0
- package/types/core/src/response.d.ts +28 -0
- package/types/core/src/routeDiscovery.d.ts +12 -0
- package/types/core/src/router.d.ts +366 -0
- package/types/core/src/scss.d.ts +19 -0
- package/types/core/src/server.d.ts +146 -0
- package/types/core/src/service.d.ts +115 -0
- package/types/core/src/session.d.ts +341 -0
- package/types/core/src/sessionHandlers/childError.d.ts +34 -0
- package/types/core/src/sessionHandlers/databaseHandler.d.ts +97 -0
- package/types/core/src/sessionHandlers/memcachedHandler.d.ts +60 -0
- package/types/core/src/sessionHandlers/mongoClient.d.ts +35 -0
- package/types/core/src/sessionHandlers/mongoHandler.d.ts +109 -0
- package/types/core/src/sessionHandlers/respClient.d.ts +22 -0
- package/types/core/src/sessionHandlers/sqlClient.d.ts +39 -0
- package/types/core/src/sessionHandlers/syncBridge.d.ts +91 -0
- package/types/core/src/sessionHandlers/syncSocket.d.ts +49 -0
- package/types/core/src/sessionHandlers/valkeyHandler.d.ts +65 -0
- package/types/core/src/static.d.ts +2 -0
- package/types/core/src/test.d.ts +94 -0
- package/types/core/src/testClient.d.ts +36 -0
- package/types/core/src/testing.d.ts +58 -0
- package/types/core/src/trustedProxy.d.ts +44 -0
- package/types/core/src/types.d.ts +242 -0
- package/types/core/src/validator.d.ts +52 -0
- package/types/core/src/websocket.d.ts +402 -0
- package/types/core/src/websocketBackplane.d.ts +166 -0
- package/types/core/src/websocketConnection.d.ts +54 -0
- package/types/core/src/wsdl.d.ts +101 -0
- package/types/frond/src/engine.d.ts +263 -0
- package/types/frond/src/index.d.ts +2 -0
- package/types/orm/src/adapters/firebird.d.ts +183 -0
- package/types/orm/src/adapters/mongodb.d.ts +81 -0
- package/types/orm/src/adapters/mssql.d.ts +77 -0
- package/types/orm/src/adapters/mysql.d.ts +67 -0
- package/types/orm/src/adapters/odbc.d.ts +94 -0
- package/types/orm/src/adapters/postgres.d.ts +86 -0
- package/types/orm/src/adapters/sqlDialect.d.ts +71 -0
- package/types/orm/src/adapters/sqlite.d.ts +68 -0
- package/types/orm/src/autoCrud.d.ts +73 -0
- package/types/orm/src/baseModel.d.ts +427 -0
- package/types/orm/src/cachedDatabase.d.ts +190 -0
- package/types/orm/src/connectTimeout.d.ts +100 -0
- package/types/orm/src/database.d.ts +655 -0
- package/types/orm/src/databaseResult.d.ts +109 -0
- package/types/orm/src/databaseUrl.d.ts +125 -0
- package/types/orm/src/docstore.d.ts +241 -0
- package/types/orm/src/fakeData.d.ts +22 -0
- package/types/orm/src/index.d.ts +43 -0
- package/types/orm/src/migration.d.ts +275 -0
- package/types/orm/src/model.d.ts +7 -0
- package/types/orm/src/query.d.ts +14 -0
- package/types/orm/src/queryBuilder.d.ts +193 -0
- package/types/orm/src/realtime/index.d.ts +7 -0
- package/types/orm/src/realtime/models/attachment.d.ts +43 -0
- package/types/orm/src/realtime/models/channel.d.ts +32 -0
- package/types/orm/src/realtime/models/channelMember.d.ts +32 -0
- package/types/orm/src/realtime/models/message.d.ts +36 -0
- package/types/orm/src/realtime/models/workspace.d.ts +26 -0
- package/types/orm/src/realtime/realtime.d.ts +24 -0
- package/types/orm/src/realtime/storage.d.ts +61 -0
- package/types/orm/src/seeder.d.ts +118 -0
- package/types/orm/src/sqlTranslator.d.ts +258 -0
- package/types/orm/src/types.d.ts +148 -0
- package/types/orm/src/validation.d.ts +6 -0
- package/types/swagger/src/generator.d.ts +46 -0
- package/types/swagger/src/index.d.ts +2 -0
- package/types/swagger/src/ui.d.ts +11 -0
- package/packages/core/src/sessionHandlers/redisHandler.ts +0 -206
|
@@ -106,7 +106,31 @@ export class DatabaseResult implements Iterable<Record<string, unknown>> {
|
|
|
106
106
|
*
|
|
107
107
|
* Returns a superset of keys for backwards-compatibility across all clients.
|
|
108
108
|
*/
|
|
109
|
-
|
|
109
|
+
/**
|
|
110
|
+
* Describe the page this result actually IS. Takes no arguments.
|
|
111
|
+
*
|
|
112
|
+
* MEASURED 2026-08-05 on a real 250-row table read with limit=20 offset=40
|
|
113
|
+
* (page 3 of 13): this reported page 1 of 2 and returned 10 of the 20 rows.
|
|
114
|
+
* It ignored the query entirely - defaulting page to 1 and perPage to 10 -
|
|
115
|
+
* then re-sliced the rows it was handed, which were already just that page.
|
|
116
|
+
* So a caller who paginated correctly at the SQL level had the answer
|
|
117
|
+
* silently re-paginated underneath them, with a page number that was simply
|
|
118
|
+
* wrong.
|
|
119
|
+
*
|
|
120
|
+
* WITH page/perPage it slices this result in memory, the behaviour GitHub
|
|
121
|
+
* issue #106 asked for. Valid ONLY when the result holds the WHOLE set
|
|
122
|
+
* (records.length >= count). A PARTIAL result cannot be sliced by page number
|
|
123
|
+
* without lying: MEASURED on 100,000 rows read under the default cap of 100,
|
|
124
|
+
* pages 1-5 of 20 were right and every page from 6 onward came back EMPTY
|
|
125
|
+
* while totalPages reported 5,000.
|
|
126
|
+
*
|
|
127
|
+
* `total` is `count`, and `count` is now the TRUE total for the filter in
|
|
128
|
+
* all four frameworks - Database.fetch runs a COUNT probe whenever it applied
|
|
129
|
+
* a limit. It used to be ROWS RETURNED here and in Ruby while Python and PHP
|
|
130
|
+
* probed, so one query answered 20 in two frameworks and 250 in the other
|
|
131
|
+
* two.
|
|
132
|
+
*/
|
|
133
|
+
toPaginate(page?: number, perPage?: number): {
|
|
110
134
|
records: Record<string, unknown>[];
|
|
111
135
|
data: Record<string, unknown>[];
|
|
112
136
|
count: number;
|
|
@@ -121,23 +145,51 @@ export class DatabaseResult implements Iterable<Record<string, unknown>> {
|
|
|
121
145
|
has_next: boolean;
|
|
122
146
|
has_prev: boolean;
|
|
123
147
|
} {
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
148
|
+
if ((page !== undefined || perPage !== undefined) && this.records.length < this.count) {
|
|
149
|
+
throw new TypeError(
|
|
150
|
+
`toPaginate(page, perPage) slices the rows this result holds, but this ` +
|
|
151
|
+
`result holds only ${this.records.length} of ${this.count} rows - it is a ` +
|
|
152
|
+
`PARTIAL result, so any page past the rows it holds comes back empty while ` +
|
|
153
|
+
`totalPages claims it exists. MEASURED on 100,000 rows read under the ` +
|
|
154
|
+
`default cap of 100: pages 1-5 of 20 were right and pages 6 onward returned ` +
|
|
155
|
+
`NOTHING. Fetch the page you want instead: fetch(sql, params, perPage, ` +
|
|
156
|
+
`(page - 1) * perPage), then call toPaginate() with no arguments.`,
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
let resolvedPerPage: number;
|
|
161
|
+
let resolvedPage: number;
|
|
162
|
+
let offset: number;
|
|
163
|
+
let rows: Record<string, unknown>[];
|
|
164
|
+
|
|
165
|
+
if (page === undefined && perPage === undefined) {
|
|
166
|
+
resolvedPerPage = this.limit > 0 ? this.limit : this.records.length;
|
|
167
|
+
resolvedPage = resolvedPerPage > 0 ? Math.floor(this.offset / resolvedPerPage) + 1 : 1;
|
|
168
|
+
offset = this.offset;
|
|
169
|
+
rows = this.records;
|
|
170
|
+
} else {
|
|
171
|
+
resolvedPage = page ?? 1;
|
|
172
|
+
resolvedPerPage = perPage ?? (this.limit > 0 ? this.limit : 10);
|
|
173
|
+
offset = (resolvedPage - 1) * resolvedPerPage;
|
|
174
|
+
rows = this.records.slice(offset, offset + resolvedPerPage);
|
|
175
|
+
}
|
|
176
|
+
const totalPages =
|
|
177
|
+
resolvedPerPage > 0 ? Math.max(1, Math.ceil(this.count / resolvedPerPage)) : 1;
|
|
178
|
+
|
|
127
179
|
return {
|
|
128
|
-
records:
|
|
129
|
-
data:
|
|
180
|
+
records: rows,
|
|
181
|
+
data: rows,
|
|
130
182
|
count: this.count,
|
|
131
183
|
total: this.count,
|
|
132
|
-
limit:
|
|
184
|
+
limit: resolvedPerPage,
|
|
133
185
|
offset,
|
|
134
|
-
page,
|
|
135
|
-
per_page:
|
|
136
|
-
perPage,
|
|
186
|
+
page: resolvedPage,
|
|
187
|
+
per_page: resolvedPerPage,
|
|
188
|
+
perPage: resolvedPerPage,
|
|
137
189
|
totalPages,
|
|
138
190
|
total_pages: totalPages,
|
|
139
|
-
has_next:
|
|
140
|
-
has_prev:
|
|
191
|
+
has_next: resolvedPage < totalPages,
|
|
192
|
+
has_prev: resolvedPage > 1,
|
|
141
193
|
};
|
|
142
194
|
}
|
|
143
195
|
|
|
@@ -216,7 +268,7 @@ export class DatabaseResult implements Iterable<Record<string, unknown>> {
|
|
|
216
268
|
if (!this._adapter) return this._fallbackColumnInfo();
|
|
217
269
|
|
|
218
270
|
try {
|
|
219
|
-
const rawCols: ColumnInfo[] = this._adapter.
|
|
271
|
+
const rawCols: ColumnInfo[] = this._adapter.getColumns(table);
|
|
220
272
|
return this._normalizeColumns(rawCols);
|
|
221
273
|
} catch {
|
|
222
274
|
return this._fallbackColumnInfo();
|
|
@@ -0,0 +1,484 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A parsed database connection URL, as a VALUE.
|
|
3
|
+
*
|
|
4
|
+
* Feature 5 of the feature audit. This used to be `parseDatabaseUrl()`, a single
|
|
5
|
+
* function with a cyclomatic complexity of 43 - the worst function measured
|
|
6
|
+
* anywhere in the audit - whose entire job is string-to-struct. It is now one
|
|
7
|
+
* small parser per engine, each well under the threshold, behind a value type
|
|
8
|
+
* with the same surface as PHP's `DatabaseUrl` (the reference for this row).
|
|
9
|
+
*
|
|
10
|
+
* Core Principle 6 says a connection string must mean literally the same thing
|
|
11
|
+
* in every framework. `test/fixtures/database_url_corpus.json` is the answer
|
|
12
|
+
* key, byte-identical in all four.
|
|
13
|
+
*/
|
|
14
|
+
import { inspect } from "node:util";
|
|
15
|
+
|
|
16
|
+
/** The canonical engine names. Aliases resolve to these ONCE, at parse. */
|
|
17
|
+
export type DatabaseEngine =
|
|
18
|
+
| "sqlite"
|
|
19
|
+
| "postgres"
|
|
20
|
+
| "mysql"
|
|
21
|
+
| "mssql"
|
|
22
|
+
| "firebird"
|
|
23
|
+
| "mongodb"
|
|
24
|
+
| "odbc";
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* URL scheme to canonical engine.
|
|
28
|
+
*
|
|
29
|
+
* `sqlite3` is accepted because the driver is literally named sqlite3 in every
|
|
30
|
+
* framework (Python's sqlite3 module, Ruby's sqlite3 gem, PHP's ext-sqlite3,
|
|
31
|
+
* Node's node:sqlite), so people type it. The "3" is a file-format version, not
|
|
32
|
+
* a different engine, which is why the canonical name stays `sqlite`.
|
|
33
|
+
*/
|
|
34
|
+
const ENGINE_ALIASES: Record<string, DatabaseEngine> = {
|
|
35
|
+
sqlite: "sqlite",
|
|
36
|
+
sqlite3: "sqlite",
|
|
37
|
+
postgres: "postgres",
|
|
38
|
+
postgresql: "postgres",
|
|
39
|
+
pgsql: "postgres",
|
|
40
|
+
mysql: "mysql",
|
|
41
|
+
mssql: "mssql",
|
|
42
|
+
sqlserver: "mssql",
|
|
43
|
+
firebird: "firebird",
|
|
44
|
+
mongodb: "mongodb",
|
|
45
|
+
"mongodb+srv": "mongodb",
|
|
46
|
+
odbc: "odbc",
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Default port per engine, applied AT PARSE.
|
|
51
|
+
*
|
|
52
|
+
* The port is part of our contract, not the driver's business. Node used to
|
|
53
|
+
* leave it unset and let the third-party driver fill in its own default, so the
|
|
54
|
+
* parsed struct for `postgresql://localhost/db` differed from PHP's while the
|
|
55
|
+
* connection still worked - a divergence hidden behind somebody else's
|
|
56
|
+
* assumption.
|
|
57
|
+
*/
|
|
58
|
+
const DEFAULT_PORTS: Partial<Record<DatabaseEngine, number>> = {
|
|
59
|
+
postgres: 5432,
|
|
60
|
+
mysql: 3306,
|
|
61
|
+
mssql: 1433,
|
|
62
|
+
firebird: 3050,
|
|
63
|
+
mongodb: 27017,
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
/** Strip EXACTLY ONE leading slash: the URL path separator, never more. */
|
|
67
|
+
function stripOneSlash(path: string): string {
|
|
68
|
+
return path.startsWith("/") ? path.slice(1) : path;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function decode(value: string | undefined): string | null {
|
|
72
|
+
if (value === undefined || value === "") return null;
|
|
73
|
+
return decodeURIComponent(value);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ── redaction ──────────────────────────────────────────────────────────────
|
|
77
|
+
// ONE primitive, used by every path that can put a connection string in front
|
|
78
|
+
// of a human. Before this, `toSafeString()` had ZERO call sites outside the
|
|
79
|
+
// corpus test - its own docblock called it "the ONLY form allowed in a log
|
|
80
|
+
// line" while the invalid-URL exception interpolated the RAW url and the odbc
|
|
81
|
+
// branch returned the connection string VERBATIM, `PWD=` and all.
|
|
82
|
+
|
|
83
|
+
/** The single mask. One spelling, so grepping for it finds every redaction. */
|
|
84
|
+
const REDACTED = "***";
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Where a keyword VALUE ends in a keyword/value connection string.
|
|
88
|
+
*
|
|
89
|
+
* NOT at the first whitespace. tina4-php redacted its connect-failure message
|
|
90
|
+
* with a `\bpassword=\S` + star pattern, and a password containing a SPACE kept
|
|
91
|
+
* its TAIL in the logged line. The real terminators are the field separators -
|
|
92
|
+
* `;` for ODBC/libpq, `&` for a query string - plus the closing brace/quote of
|
|
93
|
+
* a quoted value, since `PWD={p;w}` and the libpq quoted form both legally
|
|
94
|
+
* contain a separator.
|
|
95
|
+
*/
|
|
96
|
+
function endOfKeywordValue(text: string, start: number): number {
|
|
97
|
+
const open = text[start];
|
|
98
|
+
if (open === "{") {
|
|
99
|
+
const close = text.indexOf("}", start + 1);
|
|
100
|
+
return close === -1 ? text.length : close + 1;
|
|
101
|
+
}
|
|
102
|
+
if (open === "'" || open === '"') {
|
|
103
|
+
let i = start + 1;
|
|
104
|
+
while (i < text.length) {
|
|
105
|
+
if (text[i] === "\\") { i += 2; continue; }
|
|
106
|
+
if (text[i] === open) return i + 1;
|
|
107
|
+
i++;
|
|
108
|
+
}
|
|
109
|
+
return text.length;
|
|
110
|
+
}
|
|
111
|
+
let i = start;
|
|
112
|
+
while (i < text.length && text[i] !== ";" && text[i] !== "&") i++;
|
|
113
|
+
return i;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** `PWD=`/`password=`/`passwd=` in an ODBC DSN, a libpq DSN or a query string. */
|
|
117
|
+
const SECRET_KEYWORD_PATTERN = /(^|[;&?\s])(pwd|password|passwd)(\s*=\s*)/gi;
|
|
118
|
+
|
|
119
|
+
function redactKeywordValues(text: string): string {
|
|
120
|
+
const pattern = new RegExp(SECRET_KEYWORD_PATTERN.source, SECRET_KEYWORD_PATTERN.flags);
|
|
121
|
+
let out = "";
|
|
122
|
+
let cursor = 0;
|
|
123
|
+
let match: RegExpExecArray | null;
|
|
124
|
+
while ((match = pattern.exec(text)) !== null) {
|
|
125
|
+
const valueStart = match.index + match[0].length;
|
|
126
|
+
out += text.slice(cursor, valueStart) + REDACTED;
|
|
127
|
+
cursor = endOfKeywordValue(text, valueStart);
|
|
128
|
+
pattern.lastIndex = cursor;
|
|
129
|
+
}
|
|
130
|
+
return out + text.slice(cursor);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* The authority of `scheme://user:pass@host:port/path`, or null when the string
|
|
135
|
+
* has no `://` at all. Everything the other helpers need is derived from here,
|
|
136
|
+
* so "where does userinfo end" is decided in exactly one place.
|
|
137
|
+
*/
|
|
138
|
+
function authorityOf(raw: string): string | null {
|
|
139
|
+
const separator = raw.indexOf("://");
|
|
140
|
+
if (separator === -1) return null;
|
|
141
|
+
const rest = raw.slice(separator + 3);
|
|
142
|
+
const end = rest.search(/[/?#]/);
|
|
143
|
+
return end === -1 ? rest : rest.slice(0, end);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* The raw, still-encoded userinfo, or null when the URL carries none.
|
|
148
|
+
*
|
|
149
|
+
* Read off the RAW string on purpose: `new URL()` normalises
|
|
150
|
+
* `postgres://user:@host/db` and `postgres://user@host/db` to the identical
|
|
151
|
+
* href, so the URL object cannot tell an explicitly-blank password from an
|
|
152
|
+
* absent one (measured on Node 24.9.0 - both report `.password === ""`).
|
|
153
|
+
*/
|
|
154
|
+
function rawUserinfo(raw: string): string | null {
|
|
155
|
+
const authority = authorityOf(raw);
|
|
156
|
+
if (authority === null) return null;
|
|
157
|
+
const at = authority.lastIndexOf("@");
|
|
158
|
+
return at === -1 ? null : authority.slice(0, at);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function redactUserinfoPassword(raw: string): string {
|
|
162
|
+
const authority = authorityOf(raw);
|
|
163
|
+
if (authority === null) return raw;
|
|
164
|
+
const at = authority.lastIndexOf("@");
|
|
165
|
+
if (at === -1) return raw;
|
|
166
|
+
const colon = authority.slice(0, at).indexOf(":");
|
|
167
|
+
if (colon === -1) return raw; // a username with no password
|
|
168
|
+
const authorityStart = raw.indexOf("://") + 3;
|
|
169
|
+
return (
|
|
170
|
+
raw.slice(0, authorityStart + colon + 1) + REDACTED + raw.slice(authorityStart + at)
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Remove every credential from an arbitrary connection string.
|
|
176
|
+
*
|
|
177
|
+
* THE single redaction primitive. It works on a RAW string - valid or
|
|
178
|
+
* malformed, a URL or an ODBC DSN - so the error paths can use it too, and it
|
|
179
|
+
* is what `toSafeString()` calls for the odbc form rather than hand-rolling a
|
|
180
|
+
* second, weaker rule.
|
|
181
|
+
*
|
|
182
|
+
* It cannot be complete on a string with no recognisable credential structure
|
|
183
|
+
* (`notaurl-with-hunter2` has nothing to key off), which is exactly why the
|
|
184
|
+
* invalid-URL error reports the scheme and host instead of any form of the
|
|
185
|
+
* input. Redaction is for strings we can parse enough to redact.
|
|
186
|
+
*/
|
|
187
|
+
export function redactCredentials(raw: string): string {
|
|
188
|
+
if (typeof raw !== "string" || raw === "") return raw;
|
|
189
|
+
return redactKeywordValues(redactUserinfoPassword(raw));
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** `postgres` from `postgres://…`, or null when the string names no scheme. */
|
|
193
|
+
function schemeOf(raw: string): string | null {
|
|
194
|
+
const match = raw.match(/^([a-zA-Z][a-zA-Z0-9+.-]*):/);
|
|
195
|
+
return match ? match[1].toLowerCase() : null;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* The `host:port` slice of the authority, or null.
|
|
200
|
+
*
|
|
201
|
+
* Credential-free BY CONSTRUCTION: it is the part AFTER the last `@`, and
|
|
202
|
+
* userinfo - the only place a password may appear in a URL - is entirely
|
|
203
|
+
* before it. That is what makes it safe to name in an error message.
|
|
204
|
+
*/
|
|
205
|
+
function hostPortOf(raw: string): string | null {
|
|
206
|
+
const authority = authorityOf(raw);
|
|
207
|
+
if (authority === null) return null;
|
|
208
|
+
const at = authority.lastIndexOf("@");
|
|
209
|
+
const hostPort = at === -1 ? authority : authority.slice(at + 1);
|
|
210
|
+
return hostPort === "" ? null : hostPort;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* The failure a malformed `TINA4_DATABASE_URL` raises.
|
|
215
|
+
*
|
|
216
|
+
* The message NEVER carries the URL. The old one interpolated it, so a typo in
|
|
217
|
+
* the port wrote the password into the boot log, the crash report, the error
|
|
218
|
+
* overlay and the CI log - measured: `TINA4_DATABASE_URL` of
|
|
219
|
+
* `postgres://user:SuperSecret123@host:notaport/db` produced
|
|
220
|
+
* `DatabaseUrl: invalid URL format 'postgres://user:SuperSecret123@host:notaport/db'`.
|
|
221
|
+
*
|
|
222
|
+
* It stays diagnosable: the scheme (or engine) and the host:port are both in
|
|
223
|
+
* the message, along with the shape that was expected. A redaction that leaves
|
|
224
|
+
* nothing to debug with is its own kind of bug.
|
|
225
|
+
*/
|
|
226
|
+
function invalidUrlError(raw: string, engine?: DatabaseEngine): Error {
|
|
227
|
+
const named = engine ?? schemeOf(raw);
|
|
228
|
+
const subject = named ? `invalid ${named} URL` : "invalid URL (no scheme found)";
|
|
229
|
+
const hostPort = hostPortOf(raw);
|
|
230
|
+
const at = hostPort === null ? "" : ` at '${hostPort}'`;
|
|
231
|
+
return new Error(
|
|
232
|
+
`DatabaseUrl: ${subject}${at} - expected ` +
|
|
233
|
+
"scheme://[user[:password]@]host[:port]/database. " +
|
|
234
|
+
"The URL itself is not shown because it may contain a password."
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* DISPLAY REDACTS, FIDELITY DOES NOT. JSON.stringify, util.inspect, String() and
|
|
240
|
+
* toSafeString() replace the password with the redaction marker, so a log line, a
|
|
241
|
+
* stack or a status payload is safe. structuredClone deliberately does not: its
|
|
242
|
+
* contract is a faithful structural copy, and a masked clone would produce an
|
|
243
|
+
* object whose password is the literal "***".
|
|
244
|
+
*
|
|
245
|
+
* The consequence: DO NOT PERSIST THIS OBJECT. A DatabaseUrl structured-cloned
|
|
246
|
+
* onto a worker thread, into a cache or into a queue payload carries a cleartext
|
|
247
|
+
* credential across that boundary. Use toSafeString() instead.
|
|
248
|
+
* test/databaseUrlRedaction.test.ts fails the build if framework code ever does.
|
|
249
|
+
*/
|
|
250
|
+
export class DatabaseUrl {
|
|
251
|
+
readonly engine: DatabaseEngine;
|
|
252
|
+
/** Null for sqlite and odbc - a file or a DSN string has no host. */
|
|
253
|
+
readonly host: string | null;
|
|
254
|
+
/** Null for sqlite and odbc. Otherwise always set: the engine default applies. */
|
|
255
|
+
readonly port: number | null;
|
|
256
|
+
readonly database: string;
|
|
257
|
+
/** Null when absent, never an empty string - absent and blank differ. */
|
|
258
|
+
readonly username: string | null;
|
|
259
|
+
readonly password: string | null;
|
|
260
|
+
/** ODBC only: the raw connection string handed to odbc.connect(). */
|
|
261
|
+
readonly connectionString: string | null;
|
|
262
|
+
|
|
263
|
+
constructor(url: string, username?: string, password?: string) {
|
|
264
|
+
const parsed = DatabaseUrl.parse(url);
|
|
265
|
+
this.engine = parsed.engine;
|
|
266
|
+
this.host = parsed.host ?? null;
|
|
267
|
+
this.port = parsed.port ?? DEFAULT_PORTS[parsed.engine] ?? null;
|
|
268
|
+
this.database = parsed.database ?? "";
|
|
269
|
+
this.connectionString = parsed.connectionString ?? null;
|
|
270
|
+
// Separate credentials fill in only when the URL carried none.
|
|
271
|
+
this.username = parsed.username ?? (username ? username : null);
|
|
272
|
+
this.password = parsed.password ?? (password ? password : null);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
static fromEnv(key = "TINA4_DATABASE_URL"): DatabaseUrl | null {
|
|
276
|
+
const url = (process.env[key] ?? "").trim();
|
|
277
|
+
if (url === "") return null;
|
|
278
|
+
return new DatabaseUrl(
|
|
279
|
+
url,
|
|
280
|
+
process.env.TINA4_DATABASE_USERNAME,
|
|
281
|
+
process.env.TINA4_DATABASE_PASSWORD
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Connection target for the adapter. sqlite and odbc are the whole value.
|
|
287
|
+
*
|
|
288
|
+
* NOT SAFE TO LOG. For every network engine this is credential-free
|
|
289
|
+
* (host:port/database), which makes it look loggable - but the odbc branch
|
|
290
|
+
* returns the connection string VERBATIM, `PWD=` included, because that is
|
|
291
|
+
* what the driver has to receive. Log `toSafeString()`; never this.
|
|
292
|
+
*/
|
|
293
|
+
dsn(): string {
|
|
294
|
+
if (this.engine === "sqlite") return this.database;
|
|
295
|
+
if (this.engine === "odbc") return this.connectionString ?? "";
|
|
296
|
+
let dsn = this.host ?? "";
|
|
297
|
+
if (this.port !== null) dsn += `:${this.port}`;
|
|
298
|
+
if (this.database !== "") dsn += `/${this.database}`;
|
|
299
|
+
return dsn;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* The URL with the password replaced by ***.
|
|
304
|
+
*
|
|
305
|
+
* The ONLY form allowed in a log line or an error message: a connection URL in
|
|
306
|
+
* a log is a credential leak. Node had no such method at all before this,
|
|
307
|
+
* which meant every call site that wanted to log a connection target had to
|
|
308
|
+
* redact it by hand. It round-trips, so it stays readable as well as safe.
|
|
309
|
+
*/
|
|
310
|
+
toSafeString(): string {
|
|
311
|
+
if (this.engine === "sqlite") return `sqlite:///${this.database}`;
|
|
312
|
+
// The odbc branch used to return the connection string VERBATIM - `PWD=`
|
|
313
|
+
// and all - so the ONE method whose job is redaction handed back the
|
|
314
|
+
// password in full. The negative test "to_safe_string_never_contains_the_
|
|
315
|
+
// password" passed in all four frameworks the whole time, because the
|
|
316
|
+
// shared corpus had no odbc row: a green guard protecting nothing.
|
|
317
|
+
if (this.engine === "odbc") return `odbc:///${redactCredentials(this.connectionString ?? "")}`;
|
|
318
|
+
|
|
319
|
+
let out = `${this.engine}://`;
|
|
320
|
+
if (this.username !== null) {
|
|
321
|
+
out += this.username;
|
|
322
|
+
if (this.password !== null) out += ":***";
|
|
323
|
+
out += "@";
|
|
324
|
+
}
|
|
325
|
+
out += this.host ?? "";
|
|
326
|
+
if (this.port !== null) out += `:${this.port}`;
|
|
327
|
+
if (this.database !== "") out += `/${this.database}`;
|
|
328
|
+
return out;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* What `JSON.stringify(url)` emits.
|
|
333
|
+
*
|
|
334
|
+
* Without it, stringifying the value - directly, or as one field of a config
|
|
335
|
+
* object being logged - emitted `"password":"<the real password>"`, measured
|
|
336
|
+
* on this class. Python guards the same exposure with `__repr__` and Ruby
|
|
337
|
+
* with `#inspect`; JSON is the shape Node actually serialises into a log
|
|
338
|
+
* line, so it needs the guard too.
|
|
339
|
+
*
|
|
340
|
+
* Structure is preserved so the dump is still worth having: only the secret
|
|
341
|
+
* is masked. `null` stays `null` - an ABSENT password and a masked one are
|
|
342
|
+
* different facts, and flattening them would hide exactly the confusion C7
|
|
343
|
+
* is about.
|
|
344
|
+
*/
|
|
345
|
+
toJSON(): Record<string, unknown> {
|
|
346
|
+
return {
|
|
347
|
+
engine: this.engine,
|
|
348
|
+
host: this.host,
|
|
349
|
+
port: this.port,
|
|
350
|
+
database: this.database,
|
|
351
|
+
username: this.username,
|
|
352
|
+
password: this.password === null ? null : REDACTED,
|
|
353
|
+
connectionString:
|
|
354
|
+
this.connectionString === null ? null : redactCredentials(this.connectionString),
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* What `console.log(url)` / `util.inspect(url)` print.
|
|
360
|
+
*
|
|
361
|
+
* Node's equivalent of Python's `__repr__` and Ruby's `#inspect`, and the
|
|
362
|
+
* same rendering they produce - `DatabaseUrl('postgres://user:***@h:5432/db')`
|
|
363
|
+
* (tina4-python/tina4_python/database/database_url.py:153). Without it,
|
|
364
|
+
* `console.log(url)` printed the default field dump, password included.
|
|
365
|
+
*/
|
|
366
|
+
[inspect.custom](): string {
|
|
367
|
+
return `DatabaseUrl('${this.toSafeString()}')`;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// ── parsing ────────────────────────────────────────────────
|
|
371
|
+
// One small parser per engine. The 43-CC original is gone.
|
|
372
|
+
|
|
373
|
+
private static parse(url: string): ParsedParts {
|
|
374
|
+
if (typeof url !== "string" || url.trim() === "") {
|
|
375
|
+
throw new Error("DatabaseUrl: the URL is empty");
|
|
376
|
+
}
|
|
377
|
+
if (url.startsWith("sqlite:") || url.startsWith("sqlite3:")) {
|
|
378
|
+
return DatabaseUrl.parseSqlite(url);
|
|
379
|
+
}
|
|
380
|
+
if (url.startsWith("odbc:///")) {
|
|
381
|
+
return { engine: "odbc", connectionString: url.slice("odbc:///".length) };
|
|
382
|
+
}
|
|
383
|
+
if (url.startsWith("mssql://") || url.startsWith("sqlserver://")) {
|
|
384
|
+
return DatabaseUrl.parseRegexForm(url, "mssql", /(?:mssql|sqlserver):\/\/(?:([^:]+):([^@]+)@)?([^:/]+)(?::(\d+))?\/(.*)/);
|
|
385
|
+
}
|
|
386
|
+
if (url.startsWith("firebird://")) {
|
|
387
|
+
return DatabaseUrl.parseRegexForm(url, "firebird", /firebird:\/\/(?:([^:]+):([^@]+)@)?([^:/]+)(?::(\d+))?\/(.*)/);
|
|
388
|
+
}
|
|
389
|
+
return DatabaseUrl.parseStandard(url);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* sqlite is parsed on the RAW string. The URL class collapses `sqlite:/x` and
|
|
394
|
+
* `sqlite:///x`, losing the difference between a one-slash ABSOLUTE path and
|
|
395
|
+
* the documented three-slash RELATIVE form.
|
|
396
|
+
*
|
|
397
|
+
* sqlite:///app.db -> app.db (three slashes = relative to cwd)
|
|
398
|
+
* sqlite:////abs/app.db -> /abs/app.db (four slashes = absolute)
|
|
399
|
+
* sqlite:/abs/app.db -> /abs/app.db (one slash = a real absolute path)
|
|
400
|
+
* sqlite:app.db -> app.db
|
|
401
|
+
*/
|
|
402
|
+
private static parseSqlite(url: string): ParsedParts {
|
|
403
|
+
const normalised = url.startsWith("sqlite3:") ? `sqlite:${url.slice("sqlite3:".length)}` : url;
|
|
404
|
+
if (normalised === "sqlite::memory:" || normalised === "sqlite:///:memory:") {
|
|
405
|
+
return { engine: "sqlite", database: ":memory:" };
|
|
406
|
+
}
|
|
407
|
+
if (normalised.startsWith("sqlite:///")) {
|
|
408
|
+
return { engine: "sqlite", database: stripOneSlash(normalised.slice("sqlite://".length)) };
|
|
409
|
+
}
|
|
410
|
+
if (normalised.startsWith("sqlite://")) {
|
|
411
|
+
return { engine: "sqlite", database: normalised.slice("sqlite://".length) };
|
|
412
|
+
}
|
|
413
|
+
return { engine: "sqlite", database: normalised.slice("sqlite:".length) };
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* mssql and firebird: the URL class does not know these schemes, so they are
|
|
418
|
+
* matched directly.
|
|
419
|
+
*
|
|
420
|
+
* The captured path keeps its own leading slash when the URL had two, which is
|
|
421
|
+
* how the documented absolute Firebird form survives. The old code did
|
|
422
|
+
* `"/" + match[5]`, ADDING a slash - so an absolute path came back with two
|
|
423
|
+
* and a relative path was silently made absolute. Verified against live
|
|
424
|
+
* Firebird 5.0.4: the driver takes one or two leading slashes and rejects a
|
|
425
|
+
* relative path outright.
|
|
426
|
+
*/
|
|
427
|
+
private static parseRegexForm(url: string, engine: DatabaseEngine, pattern: RegExp): ParsedParts {
|
|
428
|
+
const m = url.match(pattern);
|
|
429
|
+
if (!m) throw invalidUrlError(url, engine);
|
|
430
|
+
return {
|
|
431
|
+
engine,
|
|
432
|
+
username: decode(m[1]),
|
|
433
|
+
password: decode(m[2]),
|
|
434
|
+
host: m[3],
|
|
435
|
+
port: m[4] ? parseInt(m[4], 10) : undefined,
|
|
436
|
+
database: m[5],
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/** postgres / mysql / mongodb, via the URL class. */
|
|
441
|
+
private static parseStandard(url: string): ParsedParts {
|
|
442
|
+
let parsed: URL;
|
|
443
|
+
try {
|
|
444
|
+
parsed = new URL(url);
|
|
445
|
+
} catch {
|
|
446
|
+
throw invalidUrlError(url);
|
|
447
|
+
}
|
|
448
|
+
const scheme = parsed.protocol.replace(/:$/, "").toLowerCase();
|
|
449
|
+
const engine = ENGINE_ALIASES[scheme];
|
|
450
|
+
if (engine === undefined) {
|
|
451
|
+
throw new Error(
|
|
452
|
+
`DatabaseUrl: Unsupported database scheme '${scheme}'. Supported: ${Object.keys(ENGINE_ALIASES).join(", ")}`
|
|
453
|
+
);
|
|
454
|
+
}
|
|
455
|
+
const database = stripOneSlash(parsed.pathname);
|
|
456
|
+
// A password that was WRITTEN but left blank (`postgres://user:@host/db`)
|
|
457
|
+
// is an explicitly-empty password, NOT an absent one, so the
|
|
458
|
+
// TINA4_DATABASE_PASSWORD fallback in the constructor must not fire for it.
|
|
459
|
+
// `decode()` collapsed both to null and the fallback DID fire, so the same
|
|
460
|
+
// .env authenticated with two different passwords depending on which
|
|
461
|
+
// framework read it. The URL object cannot tell the two apart (it
|
|
462
|
+
// normalises both to `.password === ""`), so the RAW userinfo decides.
|
|
463
|
+
const userinfo = rawUserinfo(url);
|
|
464
|
+
const passwordWasWritten = userinfo !== null && userinfo.includes(":");
|
|
465
|
+
return {
|
|
466
|
+
engine,
|
|
467
|
+
host: parsed.hostname || undefined,
|
|
468
|
+
port: parsed.port ? parseInt(parsed.port, 10) : undefined,
|
|
469
|
+
username: decode(parsed.username),
|
|
470
|
+
password: passwordWasWritten ? decodeURIComponent(parsed.password) : null,
|
|
471
|
+
database: engine === "mongodb" ? database || "tina4" : database,
|
|
472
|
+
};
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
interface ParsedParts {
|
|
477
|
+
engine: DatabaseEngine;
|
|
478
|
+
host?: string;
|
|
479
|
+
port?: number;
|
|
480
|
+
database?: string;
|
|
481
|
+
username?: string | null;
|
|
482
|
+
password?: string | null;
|
|
483
|
+
connectionString?: string;
|
|
484
|
+
}
|