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 CHANGED
@@ -1,5 +1,21 @@
1
1
  # firstly
2
2
 
3
+ ## 0.10.1
4
+
5
+ ### Patch Changes
6
+
7
+ - [#365](https://github.com/jycouet/firstly/pull/365) [`cf68997`](https://github.com/jycouet/firstly/commit/cf68997ca89672c2026ea502d197a670b942f4c0) Thanks [@jycouet](https://github.com/jycouet)! - Expose `firstly/cron` (`Roles_Cron`, `Cron`) so client code can import them, like `firstly/changeLog` already does.
8
+
9
+ - [#364](https://github.com/jycouet/firstly/pull/364) [`fcd660e`](https://github.com/jycouet/firstly/commit/fcd660ef5a8677f2dad6d4c8bc1017ae2fe46574) Thanks [@jycouet](https://github.com/jycouet)! - Accept SvelteKit 3 as a peer, prereleases included (`3.0.0-next.x`).
10
+
11
+ - [#362](https://github.com/jycouet/firstly/pull/362) [`993486d`](https://github.com/jycouet/firstly/commit/993486dd25b5c06def4874a3b3f95440bc79a845) Thanks [@jycouet](https://github.com/jycouet)! - sqlAdmin: failed queries now carry Postgres' HINT (or the closest catalog names) and the SQLSTATE, so `column a.analysisversion does not exist` comes back with `Did you mean "activities"."analysisVersion"?`. New `ff-sql` bin to run token SQL from a terminal (`--origin=`, `--api-path=`, `--json`, stdin, `FF_SQL_TOKEN`/`FF_SQL_ORIGIN`); the mint screen now copies the full command, so there is nothing to set up. Tokens can be named automatically (`swift-otter-3f9`); a dead token can be deleted - one by one, or every one of them via `purgeTokens()` - which takes its call log with it, while a revoke keeps it.
12
+
13
+ ## 0.10.0
14
+
15
+ ### Minor Changes
16
+
17
+ - [#358](https://github.com/jycouet/firstly/pull/358) [`f34be07`](https://github.com/jycouet/firstly/commit/f34be07119855025e9384ddff92f7a40dd46d8bb) Thanks [@jycouet](https://github.com/jycouet)! - sqlAdmin: `exec(sql, capabilities = ['read'])` replaces the `notReadOnly` flag - reads run in a READ ONLY transaction over the extended protocol (one statement). Opt-in `tokens` (bearer tokens to run SQL from a script or an AI through the same `exec` endpoint; acts as the minter, logged) + `<SqlTokens />`; `sqlAdmin: false` registers no controller (throws when combined with `tokens`).
18
+
3
19
  ## 0.9.4
4
20
 
5
21
  ### Patch Changes
package/bin/args.d.ts ADDED
@@ -0,0 +1,12 @@
1
+ /**
2
+ * @param {string[]} argv
3
+ * @returns {{ help: boolean, json: boolean, origin?: string, apiPath?: string, sql: string, error?: string }}
4
+ */
5
+ export function parseArgs(argv: string[]): {
6
+ help: boolean;
7
+ json: boolean;
8
+ origin?: string;
9
+ apiPath?: string;
10
+ sql: string;
11
+ error?: string;
12
+ };
package/bin/args.js ADDED
@@ -0,0 +1,66 @@
1
+ // Pure arg parsing for `ff-sql`, split out so it can be tested without running
2
+ // the cli. Plain JS like its caller: bin/ ships unbuilt.
3
+
4
+ /** Flags that take no value. */
5
+ const BOOLEAN = ['json', 'help']
6
+ /** Flags that need one, as `--x=y` or `--x y`. */
7
+ const VALUED = ['origin', 'api-path']
8
+
9
+ const camel = { 'api-path': 'apiPath' }
10
+
11
+ /** `--name` / `--name=value`. `-- comment` is SQL, not a flag. */
12
+ const FLAG = /^--[a-z][\w-]*(=|$)/
13
+
14
+ /**
15
+ * @param {string[]} argv
16
+ * @returns {{ help: boolean, json: boolean, origin?: string, apiPath?: string, sql: string, error?: string }}
17
+ */
18
+ export function parseArgs(argv) {
19
+ /** @type {any} */
20
+ const out = { help: false, json: false, sql: '' }
21
+ const positional = []
22
+ const seen = new Set()
23
+
24
+ for (let i = 0; i < argv.length; i++) {
25
+ const arg = argv[i]
26
+ if (arg === '-h') {
27
+ out.help = true
28
+ continue
29
+ }
30
+ if (arg === '--') {
31
+ positional.push(...argv.slice(i + 1))
32
+ break
33
+ }
34
+ // A `-- comment` line is SQL: only a flag-shaped token is read as a flag.
35
+ if (!FLAG.test(arg)) {
36
+ positional.push(arg)
37
+ continue
38
+ }
39
+ const eq = arg.indexOf('=')
40
+ const name = eq === -1 ? arg.slice(2) : arg.slice(2, eq)
41
+ const inline = eq === -1 ? undefined : arg.slice(eq + 1)
42
+
43
+ const key = camel[name] ?? name
44
+ if (seen.has(key)) return { ...out, error: `--${name} given twice` }
45
+
46
+ if (BOOLEAN.includes(name)) {
47
+ // `--json=false` reading as "json on" is the kind of thing nobody debugs twice.
48
+ if (inline !== undefined) return { ...out, error: `--${name} takes no value` }
49
+ seen.add(key)
50
+ out[key] = true
51
+ continue
52
+ }
53
+ if (VALUED.includes(name)) {
54
+ const value = inline ?? argv[++i]
55
+ // Without this, `--origin http://x "select 1"` sends "http://x select 1" as SQL.
56
+ if (!value || FLAG.test(value)) return { ...out, error: `--${name} needs a value` }
57
+ seen.add(key)
58
+ out[key] = value
59
+ continue
60
+ }
61
+ return { ...out, error: `unknown flag --${name}` }
62
+ }
63
+
64
+ out.sql = positional.join(' ').trim()
65
+ return out
66
+ }
package/bin/ff-sql.js ADDED
@@ -0,0 +1,87 @@
1
+ #!/usr/bin/env node
2
+ // Run SQL against a running app with a firstly sql token (`sqlAdmin({ tokens })`).
3
+ // Plain JS, shipped as-is: it must run from node_modules/.bin with no build step.
4
+ import { Console } from 'node:console'
5
+ import { text } from 'node:stream/consumers'
6
+
7
+ import { parseArgs } from './args.js'
8
+
9
+ const HELP = `ff-sql - run SQL through a firstly sql token
10
+
11
+ FF_SQL_TOKEN=ffsql_… ff-sql --origin=https://example.com "select 1"
12
+ ff-sql --json "select * from users limit 3"
13
+ ff-sql <<'SQL'
14
+ select handle from "users" where "createdAt" > now() - interval '7 days'
15
+ SQL
16
+
17
+ --json raw JSON instead of a table
18
+ -- end of flags, everything after is SQL
19
+ --origin=URL the app to query (or FF_SQL_ORIGIN)
20
+ --api-path=P remult api root (default /api)
21
+ --help, -h this
22
+
23
+ The app's <SqlTokens /> page hands you the whole command, token included -
24
+ nothing to configure here. Reads run inside a READ ONLY transaction, one
25
+ statement per call. Identifiers usually come from entity classes, so they are
26
+ camelCase and need double quotes: "createdAt", not created_at. A heredoc avoids
27
+ fighting the shell over single quotes, and keeps the token out of the line.`
28
+
29
+ const TRAILING_SLASH = /\/$/
30
+
31
+ const cli = parseArgs(process.argv.slice(2))
32
+
33
+ if (cli.help) {
34
+ console.info(HELP)
35
+ process.exit(0)
36
+ }
37
+ if (cli.error) {
38
+ console.error(`${cli.error}. ff-sql --help`)
39
+ process.exit(2)
40
+ }
41
+
42
+ const token = process.env.FF_SQL_TOKEN
43
+ const origin = cli.origin ?? process.env.FF_SQL_ORIGIN
44
+ const apiPath = cli.apiPath ?? '/api'
45
+
46
+ if (!token) {
47
+ console.error('FF_SQL_TOKEN missing - mint one in the app (<SqlTokens />). ff-sql --help')
48
+ process.exit(2)
49
+ }
50
+ if (!origin) {
51
+ console.error('origin missing - --origin=https://example.com, or FF_SQL_ORIGIN')
52
+ process.exit(2)
53
+ }
54
+
55
+ // Only read stdin when there is nothing else to run: on a tty that would hang.
56
+ let cmd = cli.sql
57
+ if (!cmd && !process.stdin.isTTY) cmd = (await text(process.stdin)).trim()
58
+ if (!cmd) {
59
+ console.error('no sql given. ff-sql --help')
60
+ process.exit(2)
61
+ }
62
+
63
+ let res
64
+ try {
65
+ res = await fetch(`${origin.replace(TRAILING_SLASH, '')}${apiPath}/ff/sqlAdmin/exec`, {
66
+ method: 'POST',
67
+ headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' },
68
+ body: JSON.stringify({ args: [cmd] }),
69
+ })
70
+ } catch (err) {
71
+ console.error(`${origin} unreachable: ${err.message}`)
72
+ process.exit(1)
73
+ }
74
+
75
+ const body = await res.json().catch(() => null)
76
+ if (!res.ok) {
77
+ // 403 here is almost always a dead token: expired, revoked, or minted elsewhere.
78
+ console.error(`${res.status} ${body?.message ?? res.statusText}`)
79
+ process.exit(1)
80
+ }
81
+
82
+ const { rows, rowCount, took } = body.data ?? body
83
+ // Table and rows on stdout, the summary on stderr, so `| jq` and `> file` stay clean.
84
+ const out = new Console(process.stdout)
85
+ if (cli.json) out.log(JSON.stringify(rows, null, 2))
86
+ else if (rowCount) out.table(rows)
87
+ console.error(`${rowCount ?? 0} row(s) · ${Math.round(took ?? 0)} ms`)
@@ -1,4 +1,5 @@
1
1
  import { Log } from '@kitql/helpers';
2
+ export { Cron } from './Cron';
2
3
  export { Roles_Cron } from './Roles_Cron';
3
4
  export declare const key = "cron";
4
5
  export declare const log: Log;
package/esm/cron/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { Log } from '@kitql/helpers';
2
+ export { Cron } from './Cron';
2
3
  export { Roles_Cron } from './Roles_Cron';
3
4
  export const key = 'cron';
4
5
  export const log = new Log(key);
@@ -1,17 +1,87 @@
1
- import { SqlDatabase } from 'remult';
1
+ import { SqlDatabase, type UserInfo } from 'remult';
2
+ import { type SqlCapability, type SqlTokenTtl } from './sqlTokenEntities';
3
+ declare module 'remult' {
4
+ interface RemultContext {
5
+ /** Set when the request authenticated with a live sql token aimed at the exec path. */
6
+ sqlToken?: {
7
+ id: string;
8
+ userId: string;
9
+ capabilities: SqlCapability[];
10
+ };
11
+ /** A sql-token bearer was sent (valid or not): apps may refuse cookie fallback on it. */
12
+ sqlTokenBearer?: boolean;
13
+ }
14
+ }
15
+ export type SqlResult = {
16
+ rows: any[];
17
+ rowCount: number;
18
+ took: number;
19
+ };
20
+ /** Just enough of `pg.Pool` for the read-only path - no dependency on pg. */
21
+ export type SqlTokenPool = {
22
+ connect(): Promise<{
23
+ query(q: string | {
24
+ text: string;
25
+ values: unknown[];
26
+ queryMode: 'extended';
27
+ }): Promise<{
28
+ rows?: any[];
29
+ }>;
30
+ release(err?: Error | boolean): void;
31
+ on(event: 'error', fn: (err: Error) => void): unknown;
32
+ off(event: 'error', fn: (err: Error) => void): unknown;
33
+ }>;
34
+ };
35
+ export type SqlTokensOptions = {
36
+ /** Which capabilities may be minted. `write` runs SQL as is - keep it out until you need it. */
37
+ capabilities: SqlCapability[];
38
+ /**
39
+ * Turns the minter's id back into the request user, so the token acts as them
40
+ * with their live roles (lose admin, tokens die). Without it the token is its
41
+ * own authority and `remult.user` stays empty.
42
+ */
43
+ userFromId?: (userId: string) => Promise<UserInfo | undefined>;
44
+ /** Raw tokens start with this; it is also how the bearer is recognized. @default 'ffsql_' */
45
+ prefix?: string;
46
+ /** Remult's api root, for the path gate. @default '/api' */
47
+ apiPath?: string;
48
+ /** @default 30 */
49
+ callLogRetentionDays?: number;
50
+ /** Override the pool used by `read` tokens. Defaults to the Postgres pool behind the data provider. */
51
+ pool?: SqlTokenPool;
52
+ };
53
+ export declare const SQL_ADMINS: ("FF_Role.Admin" | "SqlAdmin.Admin")[];
2
54
  export declare class SqlAdminController {
3
55
  /** Optional override set by the `sqlAdmin()` module's `initApi`. Falls back to `SqlDatabase.getDb()`. */
4
56
  static dp?: SqlDatabase;
57
+ /** Set by the `sqlAdmin()` module's `initApi`. */
58
+ static options: {
59
+ tokens?: SqlTokensOptions;
60
+ };
5
61
  /**
6
62
  * @param cmd SQL to run.
7
- * @param notReadOnly When `false` (default) the query runs inside a
8
- * `READ ONLY` transaction so the database itself rejects any write
9
- * (INSERT/UPDATE/DELETE/DDL). Set `true` only when you deliberately want
10
- * to mutate - the UI gates this behind an explicit checkbox.
63
+ * @param capabilities `['read']` (default) runs inside a `READ ONLY`
64
+ * transaction, one statement at a time, so the database itself rejects any
65
+ * write (INSERT/UPDATE/DELETE/DDL). Add `'write'` only when you deliberately
66
+ * want to mutate - the UI gates this behind an explicit checkbox.
67
+ *
68
+ * With a sql token the token decides, not the caller.
11
69
  */
12
- static exec(cmd: string, notReadOnly?: boolean): Promise<{
13
- rows: any[];
14
- rowCount: number;
15
- took: number;
16
- }>;
70
+ static exec(cmd: string, capabilities?: SqlCapability[]): Promise<SqlResult>;
71
+ private static run;
72
+ private static execAsToken;
73
+ /**
74
+ * Returns the raw token exactly once. Needs a live session: a token can never
75
+ * mint a token. Revoking is a plain update of `SqlToken.revokedAt`, deleting
76
+ * is a plain delete.
77
+ *
78
+ * @param name free text, or empty for an `adjective-animal` one.
79
+ */
80
+ static mintToken(name: string, capabilities: SqlCapability[], ttl: SqlTokenTtl): Promise<string>;
81
+ /**
82
+ * Deletes every token that can no longer be used (expired or revoked), and
83
+ * their calls. Returns how many were actually deleted: no transaction spans
84
+ * the loop, so a failure halfway leaves the rest in place.
85
+ */
86
+ static purgeTokens(): Promise<number>;
17
87
  }
@@ -4,41 +4,173 @@ var __decorate = (this && this.__decorate) || function (decorators, target, key,
4
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
5
  return c > 3 && r && Object.defineProperty(target, key, r), r;
6
6
  };
7
- import { BackendMethod, SqlDatabase } from 'remult';
7
+ import { BackendMethod, remult, repo, SqlDatabase } from 'remult';
8
8
  import { FF_Role } from '../core/common';
9
9
  import { Roles_SqlAdmin } from './Roles_SqlAdmin';
10
+ import { SQL_CAPABILITIES, SQL_TOKEN_TTLS, SqlToken, SqlTokenCall, } from './sqlTokenEntities';
11
+ export const SQL_ADMINS = [Roles_SqlAdmin.SqlAdmin_Admin, FF_Role.FF_Role_Admin];
12
+ const CALL_LOG_CMD_MAX = 4000;
13
+ /** Retention is a housekeeping sweep, not something to pay for on every query. */
14
+ const CALL_LOG_SWEEP_EVERY_MS = 3_600_000;
15
+ let lastCallLogSweep = 0;
16
+ function getDb() {
17
+ return SqlAdminController.dp ?? SqlDatabase.getDb();
18
+ }
19
+ /** The pg pool remult wraps, if any. `_getSourceSql` is internal but has been stable since remult 1. */
20
+ function poolFrom(db) {
21
+ const pool = db._getSourceSql?.()?.pool;
22
+ return typeof pool?.connect === 'function' ? pool : undefined;
23
+ }
10
24
  export class SqlAdminController {
11
25
  /** Optional override set by the `sqlAdmin()` module's `initApi`. Falls back to `SqlDatabase.getDb()`. */
12
26
  static dp;
27
+ /** Set by the `sqlAdmin()` module's `initApi`. */
28
+ static options = {};
13
29
  /**
14
30
  * @param cmd SQL to run.
15
- * @param notReadOnly When `false` (default) the query runs inside a
16
- * `READ ONLY` transaction so the database itself rejects any write
17
- * (INSERT/UPDATE/DELETE/DDL). Set `true` only when you deliberately want
18
- * to mutate - the UI gates this behind an explicit checkbox.
31
+ * @param capabilities `['read']` (default) runs inside a `READ ONLY`
32
+ * transaction, one statement at a time, so the database itself rejects any
33
+ * write (INSERT/UPDATE/DELETE/DDL). Add `'write'` only when you deliberately
34
+ * want to mutate - the UI gates this behind an explicit checkbox.
35
+ *
36
+ * With a sql token the token decides, not the caller.
37
+ */
38
+ static async exec(cmd, capabilities = ['read']) {
39
+ const token = remult.context.sqlToken;
40
+ if (token)
41
+ return SqlAdminController.execAsToken(token, cmd);
42
+ return SqlAdminController.run(cmd, capabilities);
43
+ }
44
+ static async run(cmd, capabilities) {
45
+ const db = getDb();
46
+ try {
47
+ // Anything that is not an explicit list (null, a stray boolean) is a read.
48
+ if (Array.isArray(capabilities) && capabilities.includes('write')) {
49
+ const start = performance.now();
50
+ const rows = (await db.execute(cmd)).rows;
51
+ return { rows, rowCount: rows.length, took: performance.now() - start };
52
+ }
53
+ const { readOnlySql } = await import('./server/readOnlySql');
54
+ return await readOnlySql(db, SqlAdminController.options.tokens?.pool ?? poolFrom(db), cmd);
55
+ }
56
+ catch (err) {
57
+ const { enrichSqlError } = await import('./server/sqlError');
58
+ throw await enrichSqlError(db, err, cmd);
59
+ }
60
+ }
61
+ static async execAsToken(token, cmd) {
62
+ const o = SqlAdminController.options.tokens;
63
+ if (!o)
64
+ throw new Error('sql tokens not enabled');
65
+ const capability = token.capabilities.includes('write') ? 'write' : 'read';
66
+ const call = repo(SqlTokenCall).create({
67
+ tokenId: token.id,
68
+ capability,
69
+ cmd: cmd.slice(0, CALL_LOG_CMD_MAX),
70
+ });
71
+ try {
72
+ const res = await SqlAdminController.run(cmd, [capability]);
73
+ call.rowCount = res.rowCount;
74
+ call.tookMs = Math.round(res.took);
75
+ return res;
76
+ }
77
+ catch (err) {
78
+ call.error = err instanceof Error ? err.message : String(err);
79
+ throw err;
80
+ }
81
+ finally {
82
+ await repo(SqlTokenCall).insert(call);
83
+ if (Date.now() - lastCallLogSweep > CALL_LOG_SWEEP_EVERY_MS) {
84
+ lastCallLogSweep = Date.now();
85
+ const days = o.callLogRetentionDays ?? 30;
86
+ await repo(SqlTokenCall).deleteMany({
87
+ where: { ts: { $lt: new Date(Date.now() - days * 86_400_000) } },
88
+ });
89
+ }
90
+ }
91
+ }
92
+ /**
93
+ * Returns the raw token exactly once. Needs a live session: a token can never
94
+ * mint a token. Revoking is a plain update of `SqlToken.revokedAt`, deleting
95
+ * is a plain delete.
96
+ *
97
+ * @param name free text, or empty for an `adjective-animal` one.
98
+ */
99
+ static async mintToken(name, capabilities, ttl) {
100
+ const o = SqlAdminController.options.tokens;
101
+ if (!o)
102
+ throw new Error('sql tokens not enabled (sqlAdmin({ tokens }))');
103
+ const userId = remult.user?.id;
104
+ if (!userId)
105
+ throw new Error('Forbidden');
106
+ const ttlMs = SQL_TOKEN_TTLS[ttl];
107
+ if (!ttlMs)
108
+ throw new Error('Unknown ttl');
109
+ const unique = [...new Set(capabilities)];
110
+ if (unique.length === 0)
111
+ throw new Error('Pick at least one capability');
112
+ for (const c of unique) {
113
+ if (!SQL_CAPABILITIES.includes(c))
114
+ throw new Error(`Unknown capability ${c}`);
115
+ if (!o.capabilities.includes(c))
116
+ throw new Error(`${c} is not enabled`);
117
+ }
118
+ const { newRawToken, hashToken, tokenHint, randomTokenName } = await import('./server/token');
119
+ const prefix = o.prefix ?? 'ffsql_';
120
+ const raw = newRawToken(prefix);
121
+ await repo(SqlToken).insert({
122
+ name: name.trim() || randomTokenName(),
123
+ hint: tokenHint(raw, prefix),
124
+ tokenHash: hashToken(raw),
125
+ userId,
126
+ capabilities: unique,
127
+ expiresAt: new Date(Date.now() + ttlMs),
128
+ });
129
+ return raw;
130
+ }
131
+ /**
132
+ * Deletes every token that can no longer be used (expired or revoked), and
133
+ * their calls. Returns how many were actually deleted: no transaction spans
134
+ * the loop, so a failure halfway leaves the rest in place.
19
135
  */
20
- static async exec(cmd, notReadOnly = false) {
21
- const db = SqlAdminController.dp ?? SqlDatabase.getDb();
22
- const start = performance.now();
23
- let rows = [];
24
- // One transaction either way: a failing multi-statement script leaves nothing behind.
25
- await db.transaction(async (tx) => {
26
- const txDb = SqlDatabase.getDb(tx);
27
- // Postgres: makes the whole transaction reject writes at the DB level.
28
- if (!notReadOnly)
29
- await txDb.execute('SET TRANSACTION READ ONLY');
30
- rows = (await txDb.execute(cmd)).rows;
136
+ static async purgeTokens() {
137
+ const dead = await repo(SqlToken).find({
138
+ where: { $or: [{ revokedAt: { $ne: null } }, { expiresAt: { $lt: new Date() } }] },
31
139
  });
32
- const took = performance.now() - start;
33
- return { rows, rowCount: rows.length, took };
140
+ // One by one: the cascade to the call log lives in the entity's hooks.
141
+ let deleted = 0;
142
+ for (const t of dead) {
143
+ await repo(SqlToken).delete(t);
144
+ deleted++;
145
+ }
146
+ return deleted;
34
147
  }
35
148
  }
36
149
  __decorate([
37
150
  BackendMethod({
38
- allowed: [Roles_SqlAdmin.SqlAdmin_Admin, FF_Role.FF_Role_Admin],
151
+ // Console: an admin session. Token: the bearer, and with `userFromId` the
152
+ // minter's live roles must still allow it.
153
+ allowed: () => {
154
+ const o = SqlAdminController.options;
155
+ if (remult.context.sqlToken)
156
+ return o.tokens?.userFromId ? remult.isAllowed(SQL_ADMINS) : true;
157
+ return remult.isAllowed(SQL_ADMINS);
158
+ },
39
159
  apiPrefix: 'ff/sqlAdmin',
40
160
  // Remult wraps BackendMethods in a transaction by default; ours would then be
41
- // nested, which Postgres rejects. We open our own below instead.
161
+ // nested ("nested transactions not allowed"). We own the transaction here.
42
162
  transactional: false,
43
163
  })
44
164
  ], SqlAdminController, "exec", null);
165
+ __decorate([
166
+ BackendMethod({
167
+ allowed: () => !!SqlAdminController.options.tokens && !remult.context.sqlToken && remult.isAllowed(SQL_ADMINS),
168
+ apiPrefix: 'ff/sqlAdmin',
169
+ })
170
+ ], SqlAdminController, "mintToken", null);
171
+ __decorate([
172
+ BackendMethod({
173
+ allowed: () => !!SqlAdminController.options.tokens && !remult.context.sqlToken && remult.isAllowed(SQL_ADMINS),
174
+ apiPrefix: 'ff/sqlAdmin',
175
+ })
176
+ ], SqlAdminController, "purgeTokens", null);
@@ -1,6 +1,8 @@
1
1
  import { Log } from '@kitql/helpers';
2
2
  export { Roles_SqlAdmin } from './Roles_SqlAdmin';
3
- export { SqlAdminController } from './SqlAdminController';
3
+ export { SqlAdminController, type SqlResult, type SqlTokensOptions } from './SqlAdminController';
4
+ export { SQL_CAPABILITIES, SQL_TOKEN_TTLS, SqlToken, SqlTokenCall, type SqlCapability, type SqlTokenTtl, } from './sqlTokenEntities';
4
5
  export { default as SqlAdmin } from './ui/SqlAdmin.svelte';
6
+ export { default as SqlTokens } from './ui/SqlTokens.svelte';
5
7
  export declare const key = "sqlAdmin";
6
8
  export declare const log: Log;
@@ -1,6 +1,8 @@
1
1
  import { Log } from '@kitql/helpers';
2
2
  export { Roles_SqlAdmin } from './Roles_SqlAdmin';
3
3
  export { SqlAdminController } from './SqlAdminController';
4
+ export { SQL_CAPABILITIES, SQL_TOKEN_TTLS, SqlToken, SqlTokenCall, } from './sqlTokenEntities';
4
5
  export { default as SqlAdmin } from './ui/SqlAdmin.svelte';
6
+ export { default as SqlTokens } from './ui/SqlTokens.svelte';
5
7
  export const key = 'sqlAdmin';
6
8
  export const log = new Log(key);
@@ -1,5 +1,7 @@
1
- import type { SqlDatabase } from 'remult';
1
+ import { type SqlDatabase } from 'remult';
2
2
  import { Module } from 'remult/server';
3
+ import { type SqlTokensOptions } from '../SqlAdminController';
4
+ export type { SqlTokensOptions, SqlTokenPool } from '../SqlAdminController';
3
5
  export type SqlAdminOptions = {
4
6
  /**
5
7
  * Override the SqlDatabase used to execute queries.
@@ -13,6 +15,20 @@ export type SqlAdminOptions = {
13
15
  * @default '/sql/admin'
14
16
  */
15
17
  path?: string;
18
+ /**
19
+ * Register the `SqlAdminController` (the `exec` endpoint behind `<SqlAdmin />`).
20
+ * `false` registers nothing - and is an error combined with `tokens`, which
21
+ * run through that same endpoint.
22
+ *
23
+ * @default true
24
+ */
25
+ sqlAdmin?: boolean;
26
+ /**
27
+ * Bearer tokens that can run SQL from outside a browser session (a script,
28
+ * an AI on a dev machine). Nothing is registered unless set - no entities,
29
+ * no mint endpoint, no request hook. Needs `sqlAdmin: true`. See `<SqlTokens />`.
30
+ */
31
+ tokens?: SqlTokensOptions | false;
16
32
  };
17
33
  /**
18
34
  * Drop-in SQL admin endpoint + companion `<SqlAdmin />` component (`firstly/sqlAdmin`).
@@ -25,16 +41,17 @@ export type SqlAdminOptions = {
25
41
  * import { sqlAdmin } from './'
26
42
  *
27
43
  * export const api = remultApi({
28
- * modules: [sqlAdmin()],
44
+ * modules: [sqlAdmin({ tokens: { capabilities: ['read'] } })],
29
45
  * })
30
46
  * ```
31
47
  *
32
48
  * Then on any admin route:
33
49
  * ```svelte
34
50
  * <script>
35
- * import { SqlAdmin } from '..'
51
+ * import { SqlAdmin, SqlTokens } from '..'
36
52
  * </script>
37
53
  * <SqlAdmin />
54
+ * <SqlTokens />
38
55
  * ```
39
56
  */
40
57
  export declare const sqlAdmin: (opts?: SqlAdminOptions) => Module<unknown>;
@@ -1,7 +1,31 @@
1
+ import { remult, repo } from 'remult';
1
2
  import { Module } from 'remult/server';
2
3
  import { yellow } from '@kitql/helpers';
3
4
  import { log } from '..';
4
5
  import { SqlAdminController } from '../SqlAdminController';
6
+ import { isSqlTokenLive, SqlToken, sqlTokenEntities } from '../sqlTokenEntities';
7
+ import { hashToken } from './token';
8
+ // Framework-agnostic: remult hands initRequest the native request (SvelteKit
9
+ // RequestEvent, express req, a fetch Request...).
10
+ function readAuthorization(req) {
11
+ const h = req?.request?.headers ?? req?.headers;
12
+ if (!h)
13
+ return '';
14
+ if (typeof h.get === 'function')
15
+ return h.get('authorization') ?? '';
16
+ return h.authorization ?? h.Authorization ?? '';
17
+ }
18
+ function readPathname(req) {
19
+ if (typeof req?.url?.pathname === 'string')
20
+ return req.url.pathname;
21
+ const u = req?.request?.url ?? req?.originalUrl ?? req?.url ?? req?.path ?? '';
22
+ try {
23
+ return new URL(u, 'http://localhost').pathname;
24
+ }
25
+ catch {
26
+ return '';
27
+ }
28
+ }
5
29
  /**
6
30
  * Drop-in SQL admin endpoint + companion `<SqlAdmin />` component (`firstly/sqlAdmin`).
7
31
  *
@@ -13,28 +37,66 @@ import { SqlAdminController } from '../SqlAdminController';
13
37
  * import { sqlAdmin } from './'
14
38
  *
15
39
  * export const api = remultApi({
16
- * modules: [sqlAdmin()],
40
+ * modules: [sqlAdmin({ tokens: { capabilities: ['read'] } })],
17
41
  * })
18
42
  * ```
19
43
  *
20
44
  * Then on any admin route:
21
45
  * ```svelte
22
46
  * <script>
23
- * import { SqlAdmin } from '..'
47
+ * import { SqlAdmin, SqlTokens } from '..'
24
48
  * </script>
25
49
  * <SqlAdmin />
50
+ * <SqlTokens />
26
51
  * ```
27
52
  */
28
53
  export const sqlAdmin = (opts) => {
29
54
  const path = opts?.path ?? '/sql/admin';
55
+ const enabled = opts?.sqlAdmin ?? true;
56
+ const tokens = opts?.tokens || undefined;
57
+ if (tokens && !enabled) {
58
+ throw new Error('sqlAdmin({ sqlAdmin: false, tokens }): tokens run through the exec endpoint');
59
+ }
60
+ const prefix = tokens?.prefix ?? 'ffsql_';
61
+ const execPath = `${tokens?.apiPath ?? '/api'}/ff/sqlAdmin/exec`;
30
62
  return new Module({
31
63
  key: 'sqlAdmin',
32
- controllers: [SqlAdminController],
64
+ // Before the app's own initRequest, so a bearer is resolved before cookies would be.
65
+ priority: -900,
66
+ entities: tokens ? Object.values(sqlTokenEntities) : [],
67
+ controllers: enabled ? [SqlAdminController] : [],
33
68
  initApi: async () => {
34
69
  if (opts?.dp) {
35
70
  SqlAdminController.dp = await opts.dp();
36
71
  }
37
- log.info(`AI Hint: visit ${yellow(path)} to query raw SQL.`);
72
+ SqlAdminController.options = { tokens };
73
+ if (enabled)
74
+ log.info(`AI Hint: visit ${yellow(path)} to query raw SQL.`);
38
75
  },
76
+ initRequest: tokens
77
+ ? async (req) => {
78
+ const auth = readAuthorization(req);
79
+ if (!auth.toLowerCase().startsWith('bearer '))
80
+ return;
81
+ const raw = auth.slice(7).trim();
82
+ if (!raw.startsWith(prefix))
83
+ return;
84
+ remult.context.sqlTokenBearer = true;
85
+ // A dead token, or one aimed anywhere but exec, authenticates nobody:
86
+ // the request stays anonymous and the target's own `allowed` rejects it.
87
+ if (readPathname(req) !== execPath)
88
+ return;
89
+ const row = await repo(SqlToken).findFirst({ tokenHash: hashToken(raw) });
90
+ if (!row || !isSqlTokenLive(row))
91
+ return;
92
+ // Throttled: this runs on every token request.
93
+ if (!row.lastUsedAt || Date.now() - row.lastUsedAt.getTime() > 60_000) {
94
+ await repo(SqlToken).update(row, { lastUsedAt: new Date() });
95
+ }
96
+ remult.context.sqlToken = { id: row.id, userId: row.userId, capabilities: row.capabilities };
97
+ if (tokens.userFromId)
98
+ remult.user = await tokens.userFromId(row.userId);
99
+ }
100
+ : undefined,
39
101
  });
40
102
  };