firstly 0.9.4 → 0.10.1
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/CHANGELOG.md +16 -0
- package/bin/args.d.ts +12 -0
- package/bin/args.js +66 -0
- package/bin/ff-sql.js +87 -0
- package/esm/cron/index.d.ts +1 -0
- package/esm/cron/index.js +1 -0
- package/esm/sqlAdmin/SqlAdminController.d.ts +80 -10
- package/esm/sqlAdmin/SqlAdminController.js +152 -20
- package/esm/sqlAdmin/index.d.ts +3 -1
- package/esm/sqlAdmin/index.js +2 -0
- package/esm/sqlAdmin/server/index.d.ts +20 -3
- package/esm/sqlAdmin/server/index.js +66 -4
- package/esm/sqlAdmin/server/readOnlySql.d.ts +18 -0
- package/esm/sqlAdmin/server/readOnlySql.js +51 -0
- package/esm/sqlAdmin/server/sqlError.d.ts +6 -0
- package/esm/sqlAdmin/server/sqlError.js +132 -0
- package/esm/sqlAdmin/server/token.d.ts +5 -0
- package/esm/sqlAdmin/server/token.js +17 -0
- package/esm/sqlAdmin/sqlTokenEntities.d.ts +42 -0
- package/esm/sqlAdmin/sqlTokenEntities.js +138 -0
- package/esm/sqlAdmin/ui/SqlAdmin.svelte +2 -2
- package/esm/sqlAdmin/ui/SqlTokens.svelte +332 -0
- package/esm/sqlAdmin/ui/SqlTokens.svelte.d.ts +12 -0
- package/package.json +9 -2
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { SqlDatabase } from 'remult';
|
|
2
|
+
import type { SqlResult, SqlTokenPool } from '../SqlAdminController';
|
|
3
|
+
/**
|
|
4
|
+
* READ ONLY is only as strong as "one statement": over the simple protocol a
|
|
5
|
+
* caller can send `commit; insert ...` and the second statement runs
|
|
6
|
+
* autocommitted, outside the read-only transaction. The extended protocol
|
|
7
|
+
* (`queryMode: 'extended'`) makes Postgres itself reject multi-statement
|
|
8
|
+
* strings, so the transaction the client started is the one the query runs in.
|
|
9
|
+
*
|
|
10
|
+
* The client goes back to the pool with DISCARD ALL: session state a SELECT can
|
|
11
|
+
* still leave behind (advisory locks, set_config) must not leak to the app.
|
|
12
|
+
* A connection the query killed (pg_terminate_backend) is destroyed, not
|
|
13
|
+
* returned, and its error is swallowed here rather than crashing the process.
|
|
14
|
+
*
|
|
15
|
+
* Without a pg pool (custom `dp`, another database) it falls back to remult's
|
|
16
|
+
* transaction + `SET TRANSACTION READ ONLY`: same intent, simple protocol.
|
|
17
|
+
*/
|
|
18
|
+
export declare function readOnlySql(db: SqlDatabase, pool: SqlTokenPool | undefined, cmd: string): Promise<SqlResult>;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { SqlDatabase } from 'remult';
|
|
2
|
+
/**
|
|
3
|
+
* READ ONLY is only as strong as "one statement": over the simple protocol a
|
|
4
|
+
* caller can send `commit; insert ...` and the second statement runs
|
|
5
|
+
* autocommitted, outside the read-only transaction. The extended protocol
|
|
6
|
+
* (`queryMode: 'extended'`) makes Postgres itself reject multi-statement
|
|
7
|
+
* strings, so the transaction the client started is the one the query runs in.
|
|
8
|
+
*
|
|
9
|
+
* The client goes back to the pool with DISCARD ALL: session state a SELECT can
|
|
10
|
+
* still leave behind (advisory locks, set_config) must not leak to the app.
|
|
11
|
+
* A connection the query killed (pg_terminate_backend) is destroyed, not
|
|
12
|
+
* returned, and its error is swallowed here rather than crashing the process.
|
|
13
|
+
*
|
|
14
|
+
* Without a pg pool (custom `dp`, another database) it falls back to remult's
|
|
15
|
+
* transaction + `SET TRANSACTION READ ONLY`: same intent, simple protocol.
|
|
16
|
+
*/
|
|
17
|
+
export async function readOnlySql(db, pool, cmd) {
|
|
18
|
+
if (!pool)
|
|
19
|
+
return readOnlyViaTransaction(db, cmd);
|
|
20
|
+
const client = await pool.connect();
|
|
21
|
+
let died;
|
|
22
|
+
const onError = (err) => {
|
|
23
|
+
died = err;
|
|
24
|
+
};
|
|
25
|
+
client.on('error', onError);
|
|
26
|
+
const start = performance.now();
|
|
27
|
+
try {
|
|
28
|
+
await client.query('BEGIN READ ONLY');
|
|
29
|
+
const res = await client.query({ text: cmd, values: [], queryMode: 'extended' });
|
|
30
|
+
const rows = res.rows ?? [];
|
|
31
|
+
return { rows, rowCount: rows.length, took: performance.now() - start };
|
|
32
|
+
}
|
|
33
|
+
finally {
|
|
34
|
+
if (!died) {
|
|
35
|
+
await client.query('ROLLBACK').catch(() => { });
|
|
36
|
+
await client.query('DISCARD ALL').catch(() => { });
|
|
37
|
+
}
|
|
38
|
+
client.off('error', onError);
|
|
39
|
+
client.release(died);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
async function readOnlyViaTransaction(db, cmd) {
|
|
43
|
+
const start = performance.now();
|
|
44
|
+
let rows = [];
|
|
45
|
+
await db.transaction(async (tx) => {
|
|
46
|
+
const txDb = SqlDatabase.getDb(tx);
|
|
47
|
+
await txDb.execute('SET TRANSACTION READ ONLY');
|
|
48
|
+
rows = (await txDb.execute(cmd)).rows;
|
|
49
|
+
});
|
|
50
|
+
return { rows, rowCount: rows.length, took: performance.now() - start };
|
|
51
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { SqlDatabase } from 'remult';
|
|
2
|
+
/**
|
|
3
|
+
* Never throws and never hides the original message - the returned error is the
|
|
4
|
+
* one to rethrow, with the code and any hint appended.
|
|
5
|
+
*/
|
|
6
|
+
export declare function enrichSqlError(db: SqlDatabase, err: unknown, cmd?: string): Promise<unknown>;
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turns "column X does not exist" into something a caller can act on without a
|
|
3
|
+
* second round trip: Postgres' own HINT when it has one, otherwise the closest
|
|
4
|
+
* names taken from the catalog.
|
|
5
|
+
*
|
|
6
|
+
* Why it matters here more than in an app: the callers of `exec` are scripts
|
|
7
|
+
* and AIs writing SQL against a schema they cannot see, and every case observed
|
|
8
|
+
* in the call log was a misremembered identifier - quoted camelCase read as
|
|
9
|
+
* snake_case, a table named in the singular. Postgres already knows the answer
|
|
10
|
+
* (`Perhaps you meant "athleteEraEstimates.estMaxBpm"`) and the raw message
|
|
11
|
+
* throws it away.
|
|
12
|
+
*/
|
|
13
|
+
const RELATION_MISSING = /relation "([^"]+)" does not exist/;
|
|
14
|
+
const COLUMN_MISSING = /column "?([\w$.]+)"? does not exist/;
|
|
15
|
+
const NON_ALPHANUM = /[^a-z0-9]/g;
|
|
16
|
+
const SQLSTATE = /^[0-9A-Z]{5}$/;
|
|
17
|
+
const UNDEFINED_TABLE = '42P01';
|
|
18
|
+
const UNDEFINED_COLUMN = '42703';
|
|
19
|
+
/** SQLSTATE class 08: the connection is gone, so asking it about its catalog only adds a timeout. */
|
|
20
|
+
const CONNECTION_CLASS = '08';
|
|
21
|
+
const MAX_CANDIDATES = 3;
|
|
22
|
+
const MIN_MATCH_LENGTH = 3;
|
|
23
|
+
/** A caller looping on bad SQL must not turn each failure into a catalog scan. */
|
|
24
|
+
const CATALOG_TTL_MS = 10_000;
|
|
25
|
+
/** Comparison key: an unquoted identifier is folded to lowercase, and `_` is how the same name is spelled in snake_case. */
|
|
26
|
+
const norm = (s) => s.toLowerCase().replace(NON_ALPHANUM, '');
|
|
27
|
+
/** `a.analysisversion` and `public.users` are about their last part. */
|
|
28
|
+
const lastPart = (s) => s.split('.').at(-1);
|
|
29
|
+
/**
|
|
30
|
+
* Same name modulo case and underscores, then one name being the start of the
|
|
31
|
+
* other - which is what a singular/plural or a truncated guess looks like
|
|
32
|
+
* (`key_value` for `keyValues`). Deliberately not a typo distance: real misses
|
|
33
|
+
* are spellings of the right word, and Postgres already suggests actual typos
|
|
34
|
+
* through its own HINT.
|
|
35
|
+
*/
|
|
36
|
+
function closest(wanted, candidates) {
|
|
37
|
+
const target = norm(wanted);
|
|
38
|
+
if (target.length < MIN_MATCH_LENGTH)
|
|
39
|
+
return [];
|
|
40
|
+
const exact = candidates.filter((c) => norm(c.name) === target);
|
|
41
|
+
if (exact.length)
|
|
42
|
+
return exact.slice(0, MAX_CANDIDATES);
|
|
43
|
+
return candidates
|
|
44
|
+
.filter((c) => {
|
|
45
|
+
const n = norm(c.name);
|
|
46
|
+
return n.length >= MIN_MATCH_LENGTH && (n.startsWith(target) || target.startsWith(n));
|
|
47
|
+
})
|
|
48
|
+
.slice(0, MAX_CANDIDATES);
|
|
49
|
+
}
|
|
50
|
+
/** A column suggestion from a table the query never mentions is noise; keep those last. */
|
|
51
|
+
function preferMentioned(candidates, cmd) {
|
|
52
|
+
const haystack = norm(cmd);
|
|
53
|
+
const mentioned = candidates.filter((c) => c.table && haystack.includes(norm(c.table)));
|
|
54
|
+
return mentioned.length ? mentioned : candidates;
|
|
55
|
+
}
|
|
56
|
+
const catalogCache = new WeakMap();
|
|
57
|
+
async function catalog(db, sql) {
|
|
58
|
+
let perDb = catalogCache.get(db);
|
|
59
|
+
if (!perDb)
|
|
60
|
+
catalogCache.set(db, (perDb = new Map()));
|
|
61
|
+
const hit = perDb.get(sql);
|
|
62
|
+
if (hit && Date.now() - hit.at < CATALOG_TTL_MS)
|
|
63
|
+
return hit.rows;
|
|
64
|
+
let rows = [];
|
|
65
|
+
try {
|
|
66
|
+
rows = (await db.execute(sql)).rows ?? [];
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
// Not Postgres, no catalog access, connection gone: the plain error is still useful.
|
|
70
|
+
}
|
|
71
|
+
perDb.set(sql, { at: Date.now(), rows });
|
|
72
|
+
return rows;
|
|
73
|
+
}
|
|
74
|
+
async function suggest(db, err, cmd) {
|
|
75
|
+
const message = err.message ?? '';
|
|
76
|
+
if (err.code === UNDEFINED_TABLE) {
|
|
77
|
+
const wanted = RELATION_MISSING.exec(message)?.[1];
|
|
78
|
+
if (!wanted)
|
|
79
|
+
return [];
|
|
80
|
+
const rows = await catalog(db, `select table_name from information_schema.tables where table_schema not in ('pg_catalog', 'information_schema')`);
|
|
81
|
+
const found = closest(lastPart(wanted), rows.map((r) => ({ label: `"${r.table_name}"`, name: r.table_name })));
|
|
82
|
+
return found.map((c) => c.label);
|
|
83
|
+
}
|
|
84
|
+
if (err.code === UNDEFINED_COLUMN) {
|
|
85
|
+
const wanted = COLUMN_MISSING.exec(message)?.[1];
|
|
86
|
+
if (!wanted)
|
|
87
|
+
return [];
|
|
88
|
+
const rows = await catalog(db, `select table_name, column_name from information_schema.columns where table_schema not in ('pg_catalog', 'information_schema')`);
|
|
89
|
+
const found = closest(lastPart(wanted), rows.map((r) => ({
|
|
90
|
+
label: `"${r.table_name}"."${r.column_name}"`,
|
|
91
|
+
name: r.column_name,
|
|
92
|
+
table: r.table_name,
|
|
93
|
+
})));
|
|
94
|
+
return preferMentioned(found, cmd).map((c) => c.label);
|
|
95
|
+
}
|
|
96
|
+
return [];
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Never throws and never hides the original message - the returned error is the
|
|
100
|
+
* one to rethrow, with the code and any hint appended.
|
|
101
|
+
*/
|
|
102
|
+
export async function enrichSqlError(db, err, cmd = '') {
|
|
103
|
+
if (!(err instanceof Error))
|
|
104
|
+
return err;
|
|
105
|
+
const pg = err;
|
|
106
|
+
// A SQLSTATE, not any error that happens to carry a `code`: ENOTFOUND is too
|
|
107
|
+
// long to match, but EPIPE and EBUSY have the exact same shape.
|
|
108
|
+
if (!pg.code || !SQLSTATE.test(pg.code) || pg.code.startsWith(CONNECTION_CLASS))
|
|
109
|
+
return err;
|
|
110
|
+
if (pg.errno !== undefined || pg.syscall !== undefined)
|
|
111
|
+
return err;
|
|
112
|
+
const parts = [pg.message];
|
|
113
|
+
if (pg.hint)
|
|
114
|
+
parts.push(pg.hint);
|
|
115
|
+
else {
|
|
116
|
+
const found = await suggest(db, pg, cmd);
|
|
117
|
+
if (found.length)
|
|
118
|
+
parts.push(`Did you mean ${found.join(' or ')}?`);
|
|
119
|
+
}
|
|
120
|
+
if (pg.detail)
|
|
121
|
+
parts.push(pg.detail);
|
|
122
|
+
parts.push(`[${pg.code}${pg.position ? ` at ${pg.position}` : ''}]`);
|
|
123
|
+
const out = new Error(parts.join(' · '));
|
|
124
|
+
Object.assign(out, {
|
|
125
|
+
code: pg.code,
|
|
126
|
+
hint: pg.hint,
|
|
127
|
+
detail: pg.detail,
|
|
128
|
+
position: pg.position,
|
|
129
|
+
cause: err,
|
|
130
|
+
});
|
|
131
|
+
return out;
|
|
132
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export declare function newRawToken(prefix: string): string;
|
|
2
|
+
export declare function hashToken(raw: string): string;
|
|
3
|
+
export declare function tokenHint(raw: string, prefix: string): string;
|
|
4
|
+
/** `swift-otter-3f9`: a name you can say out loud when asking "who is running this query?". */
|
|
5
|
+
export declare function randomTokenName(): string;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { createHash, randomBytes, randomInt } from 'node:crypto';
|
|
2
|
+
export function newRawToken(prefix) {
|
|
3
|
+
return prefix + randomBytes(32).toString('base64url');
|
|
4
|
+
}
|
|
5
|
+
export function hashToken(raw) {
|
|
6
|
+
return createHash('sha256').update(raw).digest('hex');
|
|
7
|
+
}
|
|
8
|
+
export function tokenHint(raw, prefix) {
|
|
9
|
+
return raw.slice(0, prefix.length + 8);
|
|
10
|
+
}
|
|
11
|
+
const ADJECTIVES = 'amber bold brave calm clever cosmic crisp eager fuzzy gentle happy jolly keen lucky mellow neat nimble plucky quiet rapid rusty shiny silent sly snappy sunny swift tidy vivid witty zesty'.split(' ');
|
|
12
|
+
const ANIMALS = 'otter badger falcon heron ibex lynx marmot narwhal ocelot panda quokka raven seal tapir urchin viper walrus yak zebu gecko koala llama mantis newt puffin stoat toucan vole wombat'.split(' ');
|
|
13
|
+
/** `swift-otter-3f9`: a name you can say out loud when asking "who is running this query?". */
|
|
14
|
+
export function randomTokenName() {
|
|
15
|
+
const pick = (xs) => xs[randomInt(xs.length)];
|
|
16
|
+
return `${pick(ADJECTIVES)}-${pick(ANIMALS)}-${randomBytes(2).toString('hex').slice(0, 3)}`;
|
|
17
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/** What a call may do: `read` runs inside a READ ONLY transaction (one statement), `write` runs SQL as is. */
|
|
2
|
+
export declare const SQL_CAPABILITIES: readonly ["read", "write"];
|
|
3
|
+
export type SqlCapability = (typeof SQL_CAPABILITIES)[number];
|
|
4
|
+
export declare const SQL_TOKEN_TTLS: {
|
|
5
|
+
readonly '1h': 3600000;
|
|
6
|
+
readonly '24h': 86400000;
|
|
7
|
+
readonly '7d': 604800000;
|
|
8
|
+
};
|
|
9
|
+
export type SqlTokenTtl = keyof typeof SQL_TOKEN_TTLS;
|
|
10
|
+
export declare const SQL_TOKEN_DELETE_LIVE = "Revoke a live token before deleting it";
|
|
11
|
+
export declare class SqlToken {
|
|
12
|
+
id: string;
|
|
13
|
+
name: string;
|
|
14
|
+
/** First chars of the raw token, so a leaked value can be matched to its row. */
|
|
15
|
+
hint: string;
|
|
16
|
+
tokenHash: string;
|
|
17
|
+
/** Minter (`remult.user.id`). The token acts as this user when `userFromId` is configured. */
|
|
18
|
+
userId: string;
|
|
19
|
+
capabilities: SqlCapability[];
|
|
20
|
+
createdAt: Date;
|
|
21
|
+
expiresAt: Date;
|
|
22
|
+
lastUsedAt: Date | null;
|
|
23
|
+
revokedAt: Date | null;
|
|
24
|
+
}
|
|
25
|
+
export declare class SqlTokenCall {
|
|
26
|
+
id: string;
|
|
27
|
+
tokenId: string;
|
|
28
|
+
capability: string;
|
|
29
|
+
cmd: string;
|
|
30
|
+
rowCount: number;
|
|
31
|
+
tookMs: number;
|
|
32
|
+
error: string | null;
|
|
33
|
+
ts: Date;
|
|
34
|
+
}
|
|
35
|
+
export declare const sqlTokenEntities: {
|
|
36
|
+
SqlToken: typeof SqlToken;
|
|
37
|
+
SqlTokenCall: typeof SqlTokenCall;
|
|
38
|
+
};
|
|
39
|
+
export declare function isSqlTokenLive(t: {
|
|
40
|
+
expiresAt: Date;
|
|
41
|
+
revokedAt: Date | null;
|
|
42
|
+
}, now?: number): boolean;
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
|
|
2
|
+
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
3
|
+
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
4
|
+
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
|
5
|
+
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
6
|
+
};
|
|
7
|
+
import { Entity, Fields, repo } from 'remult';
|
|
8
|
+
import { FF_Role } from '../core/common';
|
|
9
|
+
import { Roles_SqlAdmin } from './Roles_SqlAdmin';
|
|
10
|
+
/** What a call may do: `read` runs inside a READ ONLY transaction (one statement), `write` runs SQL as is. */
|
|
11
|
+
export const SQL_CAPABILITIES = ['read', 'write'];
|
|
12
|
+
export const SQL_TOKEN_TTLS = { '1h': 3_600_000, '24h': 86_400_000, '7d': 604_800_000 };
|
|
13
|
+
const admins = [Roles_SqlAdmin.SqlAdmin_Admin, FF_Role.FF_Role_Admin];
|
|
14
|
+
// Minting is `SqlAdminController.mintToken` (the raw value is generated
|
|
15
|
+
// server-side, shown once, never stored). Revoking is the one update the API
|
|
16
|
+
// allows: set `revokedAt`; nothing else is writable and a revoke is final.
|
|
17
|
+
//
|
|
18
|
+
// Revoke and delete are not the same gesture: revoking is the kill switch and
|
|
19
|
+
// keeps the row plus its call log, which is the audit trail; deleting is
|
|
20
|
+
// cleanup and takes the calls with it - a call log pointing at a token nobody
|
|
21
|
+
// can name is noise, not history. So only a dead token can be deleted: killing
|
|
22
|
+
// a live one is always a revoke.
|
|
23
|
+
export const SQL_TOKEN_DELETE_LIVE = 'Revoke a live token before deleting it';
|
|
24
|
+
let SqlToken = class SqlToken {
|
|
25
|
+
id = '';
|
|
26
|
+
name = '';
|
|
27
|
+
/** First chars of the raw token, so a leaked value can be matched to its row. */
|
|
28
|
+
hint = '';
|
|
29
|
+
tokenHash = '';
|
|
30
|
+
/** Minter (`remult.user.id`). The token acts as this user when `userFromId` is configured. */
|
|
31
|
+
userId = '';
|
|
32
|
+
capabilities = [];
|
|
33
|
+
createdAt = new Date();
|
|
34
|
+
expiresAt = new Date();
|
|
35
|
+
lastUsedAt = null;
|
|
36
|
+
revokedAt = null;
|
|
37
|
+
};
|
|
38
|
+
__decorate([
|
|
39
|
+
Fields.id()
|
|
40
|
+
], SqlToken.prototype, "id", void 0);
|
|
41
|
+
__decorate([
|
|
42
|
+
Fields.string({ allowApiUpdate: false })
|
|
43
|
+
], SqlToken.prototype, "name", void 0);
|
|
44
|
+
__decorate([
|
|
45
|
+
Fields.string({ allowApiUpdate: false })
|
|
46
|
+
], SqlToken.prototype, "hint", void 0);
|
|
47
|
+
__decorate([
|
|
48
|
+
Fields.string({ includeInApi: false })
|
|
49
|
+
], SqlToken.prototype, "tokenHash", void 0);
|
|
50
|
+
__decorate([
|
|
51
|
+
Fields.string({ allowApiUpdate: false })
|
|
52
|
+
], SqlToken.prototype, "userId", void 0);
|
|
53
|
+
__decorate([
|
|
54
|
+
Fields.json({ allowApiUpdate: false })
|
|
55
|
+
], SqlToken.prototype, "capabilities", void 0);
|
|
56
|
+
__decorate([
|
|
57
|
+
Fields.createdAt()
|
|
58
|
+
], SqlToken.prototype, "createdAt", void 0);
|
|
59
|
+
__decorate([
|
|
60
|
+
Fields.date({ allowApiUpdate: false })
|
|
61
|
+
], SqlToken.prototype, "expiresAt", void 0);
|
|
62
|
+
__decorate([
|
|
63
|
+
Fields.date({ allowNull: true, allowApiUpdate: false })
|
|
64
|
+
], SqlToken.prototype, "lastUsedAt", void 0);
|
|
65
|
+
__decorate([
|
|
66
|
+
Fields.date({ allowNull: true })
|
|
67
|
+
], SqlToken.prototype, "revokedAt", void 0);
|
|
68
|
+
SqlToken = __decorate([
|
|
69
|
+
Entity('_ff_sql_tokens', {
|
|
70
|
+
caption: 'FF Sql Tokens',
|
|
71
|
+
// Admins means a session: a bearer is only ever resolved for the exec path
|
|
72
|
+
// (`server/index.ts`), so a token reaching this entity is an anonymous request.
|
|
73
|
+
allowApiRead: admins,
|
|
74
|
+
allowApiUpdate: admins,
|
|
75
|
+
allowApiDelete: admins,
|
|
76
|
+
defaultOrderBy: { createdAt: 'desc' },
|
|
77
|
+
saving: (t, e) => {
|
|
78
|
+
if (!e.isNew && e.fields.revokedAt.valueChanged() && !t.revokedAt) {
|
|
79
|
+
throw new Error('A revoked token stays revoked');
|
|
80
|
+
}
|
|
81
|
+
},
|
|
82
|
+
deleting: (t) => {
|
|
83
|
+
if (isSqlTokenLive(t))
|
|
84
|
+
throw new Error(SQL_TOKEN_DELETE_LIVE);
|
|
85
|
+
},
|
|
86
|
+
// After, not before: a failed row delete must not have dropped the calls already.
|
|
87
|
+
deleted: async (t) => {
|
|
88
|
+
await repo(SqlTokenCall).deleteMany({ where: { tokenId: t.id } });
|
|
89
|
+
},
|
|
90
|
+
})
|
|
91
|
+
], SqlToken);
|
|
92
|
+
export { SqlToken };
|
|
93
|
+
let SqlTokenCall = class SqlTokenCall {
|
|
94
|
+
id = '';
|
|
95
|
+
tokenId = '';
|
|
96
|
+
capability = '';
|
|
97
|
+
cmd = '';
|
|
98
|
+
rowCount = 0;
|
|
99
|
+
tookMs = 0;
|
|
100
|
+
error = null;
|
|
101
|
+
ts = new Date();
|
|
102
|
+
};
|
|
103
|
+
__decorate([
|
|
104
|
+
Fields.id()
|
|
105
|
+
], SqlTokenCall.prototype, "id", void 0);
|
|
106
|
+
__decorate([
|
|
107
|
+
Fields.string()
|
|
108
|
+
], SqlTokenCall.prototype, "tokenId", void 0);
|
|
109
|
+
__decorate([
|
|
110
|
+
Fields.string()
|
|
111
|
+
], SqlTokenCall.prototype, "capability", void 0);
|
|
112
|
+
__decorate([
|
|
113
|
+
Fields.string()
|
|
114
|
+
], SqlTokenCall.prototype, "cmd", void 0);
|
|
115
|
+
__decorate([
|
|
116
|
+
Fields.integer()
|
|
117
|
+
], SqlTokenCall.prototype, "rowCount", void 0);
|
|
118
|
+
__decorate([
|
|
119
|
+
Fields.integer()
|
|
120
|
+
], SqlTokenCall.prototype, "tookMs", void 0);
|
|
121
|
+
__decorate([
|
|
122
|
+
Fields.string({ allowNull: true })
|
|
123
|
+
], SqlTokenCall.prototype, "error", void 0);
|
|
124
|
+
__decorate([
|
|
125
|
+
Fields.createdAt()
|
|
126
|
+
], SqlTokenCall.prototype, "ts", void 0);
|
|
127
|
+
SqlTokenCall = __decorate([
|
|
128
|
+
Entity('_ff_sql_token_calls', {
|
|
129
|
+
caption: 'FF Sql Token Calls',
|
|
130
|
+
allowApiRead: admins,
|
|
131
|
+
defaultOrderBy: { ts: 'desc' },
|
|
132
|
+
})
|
|
133
|
+
], SqlTokenCall);
|
|
134
|
+
export { SqlTokenCall };
|
|
135
|
+
export const sqlTokenEntities = { SqlToken, SqlTokenCall };
|
|
136
|
+
export function isSqlTokenLive(t, now = Date.now()) {
|
|
137
|
+
return !t.revokedAt && t.expiresAt.getTime() > now;
|
|
138
|
+
}
|
|
@@ -64,7 +64,7 @@ ORDER BY tablename, indexname`,
|
|
|
64
64
|
try {
|
|
65
65
|
error = ''
|
|
66
66
|
isLoading = true
|
|
67
|
-
result = await SqlAdminController.exec(sqlInput, allowWrites)
|
|
67
|
+
result = await SqlAdminController.exec(sqlInput, allowWrites ? ['read', 'write'] : ['read'])
|
|
68
68
|
log.info('for AI:', JSON.stringify(result.rows))
|
|
69
69
|
log.info('for humans:', result)
|
|
70
70
|
} catch (e) {
|
|
@@ -165,7 +165,7 @@ ORDER BY tablename, indexname`,
|
|
|
165
165
|
class="inline-flex items-center gap-2 text-sm font-medium select-none"
|
|
166
166
|
class:text-destructive={allowWrites}
|
|
167
167
|
class:text-muted-foreground={!allowWrites}
|
|
168
|
-
title="Unchecked: the query runs in a READ ONLY transaction, so the database rejects any write. Tick only when you know what you are doing."
|
|
168
|
+
title="Unchecked: the query runs in a READ ONLY transaction, one statement at a time, so the database rejects any write. Tick only when you know what you are doing."
|
|
169
169
|
>
|
|
170
170
|
<input type="checkbox" bind:checked={allowWrites} class="accent-destructive" />
|
|
171
171
|
Allow writes
|