firstly 0.9.3 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # firstly
2
2
 
3
+ ## 0.10.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [#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`).
8
+
9
+ ## 0.9.4
10
+
11
+ ### Patch Changes
12
+
13
+ - [#352](https://github.com/jycouet/firstly/pull/352) [`53ec5e1`](https://github.com/jycouet/firstly/commit/53ec5e12f744ed207b9fce6fa80f3bc9b4918a92) Thanks [@jycouet](https://github.com/jycouet)! - feedback: `getIssues` returned nothing when `milestones.labels_filters` was unset (GitHub treats `labels: []` as "match nothing"); the filter is now omitted in that case.
14
+
3
15
  ## 0.9.3
4
16
 
5
17
  ### Patch Changes
@@ -138,7 +138,10 @@ export class FeedbackController {
138
138
  owner: remult.context.feedbackOptions.repo.owner,
139
139
  milestoneNumber,
140
140
  filters: {
141
- labels: remult.context.feedbackOptions.milestones?.labels_filters ?? [],
141
+ // GitHub treats `labels: []` as "match nothing", so omit the key when unset.
142
+ labels: remult.context.feedbackOptions.milestones?.labels_filters?.length
143
+ ? remult.context.feedbackOptions.milestones.labels_filters
144
+ : undefined,
142
145
  states: [issueState],
143
146
  },
144
147
  issueOrder,
@@ -1,17 +1,78 @@
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`.
76
+ */
77
+ static mintToken(name: string, capabilities: SqlCapability[], ttl: SqlTokenTtl): Promise<string>;
17
78
  }
@@ -4,41 +4,138 @@ 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
+ function getDb() {
14
+ return SqlAdminController.dp ?? SqlDatabase.getDb();
15
+ }
16
+ /** The pg pool remult wraps, if any. `_getSourceSql` is internal but has been stable since remult 1. */
17
+ function poolFrom(db) {
18
+ const pool = db._getSourceSql?.()?.pool;
19
+ return typeof pool?.connect === 'function' ? pool : undefined;
20
+ }
10
21
  export class SqlAdminController {
11
22
  /** Optional override set by the `sqlAdmin()` module's `initApi`. Falls back to `SqlDatabase.getDb()`. */
12
23
  static dp;
24
+ /** Set by the `sqlAdmin()` module's `initApi`. */
25
+ static options = {};
13
26
  /**
14
27
  * @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.
28
+ * @param capabilities `['read']` (default) runs inside a `READ ONLY`
29
+ * transaction, one statement at a time, so the database itself rejects any
30
+ * write (INSERT/UPDATE/DELETE/DDL). Add `'write'` only when you deliberately
31
+ * want to mutate - the UI gates this behind an explicit checkbox.
32
+ *
33
+ * With a sql token the token decides, not the caller.
34
+ */
35
+ static async exec(cmd, capabilities = ['read']) {
36
+ const token = remult.context.sqlToken;
37
+ if (token)
38
+ return SqlAdminController.execAsToken(token, cmd);
39
+ return SqlAdminController.run(cmd, capabilities);
40
+ }
41
+ static async run(cmd, capabilities) {
42
+ const db = getDb();
43
+ // Anything that is not an explicit list (null, a stray boolean) is a read.
44
+ if (Array.isArray(capabilities) && capabilities.includes('write')) {
45
+ const start = performance.now();
46
+ const rows = (await db.execute(cmd)).rows;
47
+ return { rows, rowCount: rows.length, took: performance.now() - start };
48
+ }
49
+ const { readOnlySql } = await import('./server/readOnlySql');
50
+ return readOnlySql(db, SqlAdminController.options.tokens?.pool ?? poolFrom(db), cmd);
51
+ }
52
+ static async execAsToken(token, cmd) {
53
+ const o = SqlAdminController.options.tokens;
54
+ if (!o)
55
+ throw new Error('sql tokens not enabled');
56
+ const capability = token.capabilities.includes('write') ? 'write' : 'read';
57
+ const call = repo(SqlTokenCall).create({
58
+ tokenId: token.id,
59
+ capability,
60
+ cmd: cmd.slice(0, CALL_LOG_CMD_MAX),
61
+ });
62
+ try {
63
+ const res = await SqlAdminController.run(cmd, [capability]);
64
+ call.rowCount = res.rowCount;
65
+ call.tookMs = Math.round(res.took);
66
+ return res;
67
+ }
68
+ catch (err) {
69
+ call.error = err instanceof Error ? err.message : String(err);
70
+ throw err;
71
+ }
72
+ finally {
73
+ await repo(SqlTokenCall).insert(call);
74
+ const days = o.callLogRetentionDays ?? 30;
75
+ await repo(SqlTokenCall).deleteMany({
76
+ where: { ts: { $lt: new Date(Date.now() - days * 86_400_000) } },
77
+ });
78
+ }
79
+ }
80
+ /**
81
+ * Returns the raw token exactly once. Needs a live session: a token can never
82
+ * mint a token. Revoking is a plain update of `SqlToken.revokedAt`.
19
83
  */
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;
84
+ static async mintToken(name, capabilities, ttl) {
85
+ const o = SqlAdminController.options.tokens;
86
+ if (!o)
87
+ throw new Error('sql tokens not enabled (sqlAdmin({ tokens }))');
88
+ const userId = remult.user?.id;
89
+ if (!userId)
90
+ throw new Error('Forbidden');
91
+ const clean = name.trim();
92
+ if (!clean)
93
+ throw new Error('Name is required');
94
+ const ttlMs = SQL_TOKEN_TTLS[ttl];
95
+ if (!ttlMs)
96
+ throw new Error('Unknown ttl');
97
+ const unique = [...new Set(capabilities)];
98
+ if (unique.length === 0)
99
+ throw new Error('Pick at least one capability');
100
+ for (const c of unique) {
101
+ if (!SQL_CAPABILITIES.includes(c))
102
+ throw new Error(`Unknown capability ${c}`);
103
+ if (!o.capabilities.includes(c))
104
+ throw new Error(`${c} is not enabled`);
105
+ }
106
+ const { newRawToken, hashToken, tokenHint } = await import('./server/token');
107
+ const prefix = o.prefix ?? 'ffsql_';
108
+ const raw = newRawToken(prefix);
109
+ await repo(SqlToken).insert({
110
+ name: clean,
111
+ hint: tokenHint(raw, prefix),
112
+ tokenHash: hashToken(raw),
113
+ userId,
114
+ capabilities: unique,
115
+ expiresAt: new Date(Date.now() + ttlMs),
31
116
  });
32
- const took = performance.now() - start;
33
- return { rows, rowCount: rows.length, took };
117
+ return raw;
34
118
  }
35
119
  }
36
120
  __decorate([
37
121
  BackendMethod({
38
- allowed: [Roles_SqlAdmin.SqlAdmin_Admin, FF_Role.FF_Role_Admin],
122
+ // Console: an admin session. Token: the bearer, and with `userFromId` the
123
+ // minter's live roles must still allow it.
124
+ allowed: () => {
125
+ const o = SqlAdminController.options;
126
+ if (remult.context.sqlToken)
127
+ return o.tokens?.userFromId ? remult.isAllowed(SQL_ADMINS) : true;
128
+ return remult.isAllowed(SQL_ADMINS);
129
+ },
39
130
  apiPrefix: 'ff/sqlAdmin',
40
131
  // Remult wraps BackendMethods in a transaction by default; ours would then be
41
- // nested, which Postgres rejects. We open our own below instead.
132
+ // nested ("nested transactions not allowed"). We own the transaction here.
42
133
  transactional: false,
43
134
  })
44
135
  ], SqlAdminController, "exec", null);
136
+ __decorate([
137
+ BackendMethod({
138
+ allowed: () => !!SqlAdminController.options.tokens && !remult.context.sqlToken && remult.isAllowed(SQL_ADMINS),
139
+ apiPrefix: 'ff/sqlAdmin',
140
+ })
141
+ ], SqlAdminController, "mintToken", 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
  };
@@ -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,3 @@
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;
@@ -0,0 +1,10 @@
1
+ import { createHash, randomBytes } 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
+ }
@@ -0,0 +1,41 @@
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 class SqlToken {
11
+ id: string;
12
+ name: string;
13
+ /** First chars of the raw token, so a leaked value can be matched to its row. */
14
+ hint: string;
15
+ tokenHash: string;
16
+ /** Minter (`remult.user.id`). The token acts as this user when `userFromId` is configured. */
17
+ userId: string;
18
+ capabilities: SqlCapability[];
19
+ createdAt: Date;
20
+ expiresAt: Date;
21
+ lastUsedAt: Date | null;
22
+ revokedAt: Date | null;
23
+ }
24
+ export declare class SqlTokenCall {
25
+ id: string;
26
+ tokenId: string;
27
+ capability: string;
28
+ cmd: string;
29
+ rowCount: number;
30
+ tookMs: number;
31
+ error: string | null;
32
+ ts: Date;
33
+ }
34
+ export declare const sqlTokenEntities: {
35
+ SqlToken: typeof SqlToken;
36
+ SqlTokenCall: typeof SqlTokenCall;
37
+ };
38
+ export declare function isSqlTokenLive(t: {
39
+ expiresAt: Date;
40
+ revokedAt: Date | null;
41
+ }, now?: number): boolean;
@@ -0,0 +1,120 @@
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 } 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
+ let SqlToken = class SqlToken {
18
+ id = '';
19
+ name = '';
20
+ /** First chars of the raw token, so a leaked value can be matched to its row. */
21
+ hint = '';
22
+ tokenHash = '';
23
+ /** Minter (`remult.user.id`). The token acts as this user when `userFromId` is configured. */
24
+ userId = '';
25
+ capabilities = [];
26
+ createdAt = new Date();
27
+ expiresAt = new Date();
28
+ lastUsedAt = null;
29
+ revokedAt = null;
30
+ };
31
+ __decorate([
32
+ Fields.id()
33
+ ], SqlToken.prototype, "id", void 0);
34
+ __decorate([
35
+ Fields.string({ allowApiUpdate: false })
36
+ ], SqlToken.prototype, "name", void 0);
37
+ __decorate([
38
+ Fields.string({ allowApiUpdate: false })
39
+ ], SqlToken.prototype, "hint", void 0);
40
+ __decorate([
41
+ Fields.string({ includeInApi: false })
42
+ ], SqlToken.prototype, "tokenHash", void 0);
43
+ __decorate([
44
+ Fields.string({ allowApiUpdate: false })
45
+ ], SqlToken.prototype, "userId", void 0);
46
+ __decorate([
47
+ Fields.json({ allowApiUpdate: false })
48
+ ], SqlToken.prototype, "capabilities", void 0);
49
+ __decorate([
50
+ Fields.createdAt()
51
+ ], SqlToken.prototype, "createdAt", void 0);
52
+ __decorate([
53
+ Fields.date({ allowApiUpdate: false })
54
+ ], SqlToken.prototype, "expiresAt", void 0);
55
+ __decorate([
56
+ Fields.date({ allowNull: true, allowApiUpdate: false })
57
+ ], SqlToken.prototype, "lastUsedAt", void 0);
58
+ __decorate([
59
+ Fields.date({ allowNull: true })
60
+ ], SqlToken.prototype, "revokedAt", void 0);
61
+ SqlToken = __decorate([
62
+ Entity('_ff_sql_tokens', {
63
+ caption: 'FF Sql Tokens',
64
+ allowApiRead: admins,
65
+ allowApiUpdate: admins,
66
+ defaultOrderBy: { createdAt: 'desc' },
67
+ saving: (t, e) => {
68
+ if (!e.isNew && e.fields.revokedAt.valueChanged() && !t.revokedAt) {
69
+ throw new Error('A revoked token stays revoked');
70
+ }
71
+ },
72
+ })
73
+ ], SqlToken);
74
+ export { SqlToken };
75
+ let SqlTokenCall = class SqlTokenCall {
76
+ id = '';
77
+ tokenId = '';
78
+ capability = '';
79
+ cmd = '';
80
+ rowCount = 0;
81
+ tookMs = 0;
82
+ error = null;
83
+ ts = new Date();
84
+ };
85
+ __decorate([
86
+ Fields.id()
87
+ ], SqlTokenCall.prototype, "id", void 0);
88
+ __decorate([
89
+ Fields.string()
90
+ ], SqlTokenCall.prototype, "tokenId", void 0);
91
+ __decorate([
92
+ Fields.string()
93
+ ], SqlTokenCall.prototype, "capability", void 0);
94
+ __decorate([
95
+ Fields.string()
96
+ ], SqlTokenCall.prototype, "cmd", void 0);
97
+ __decorate([
98
+ Fields.integer()
99
+ ], SqlTokenCall.prototype, "rowCount", void 0);
100
+ __decorate([
101
+ Fields.integer()
102
+ ], SqlTokenCall.prototype, "tookMs", void 0);
103
+ __decorate([
104
+ Fields.string({ allowNull: true })
105
+ ], SqlTokenCall.prototype, "error", void 0);
106
+ __decorate([
107
+ Fields.createdAt()
108
+ ], SqlTokenCall.prototype, "ts", void 0);
109
+ SqlTokenCall = __decorate([
110
+ Entity('_ff_sql_token_calls', {
111
+ caption: 'FF Sql Token Calls',
112
+ allowApiRead: admins,
113
+ defaultOrderBy: { ts: 'desc' },
114
+ })
115
+ ], SqlTokenCall);
116
+ export { SqlTokenCall };
117
+ export const sqlTokenEntities = { SqlToken, SqlTokenCall };
118
+ export function isSqlTokenLive(t, now = Date.now()) {
119
+ return !t.revokedAt && t.expiresAt.getTime() > now;
120
+ }
@@ -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
@@ -0,0 +1,268 @@
1
+ <script lang="ts">
2
+ /**
3
+ * Sql tokens UI: mint, list/revoke, last calls. Styled against the semantic
4
+ * theme tokens like `<SqlAdmin />`. Server side: `sqlAdmin({ tokens })`.
5
+ * Revoking is a plain entity update; only `revokedAt` is writable.
6
+ */
7
+ import { repo } from 'remult'
8
+
9
+ import { log } from '../index'
10
+ import { SqlAdminController } from '../SqlAdminController'
11
+ import {
12
+ SQL_CAPABILITIES,
13
+ SQL_TOKEN_TTLS,
14
+ SqlToken,
15
+ SqlTokenCall,
16
+ type SqlCapability,
17
+ type SqlTokenTtl,
18
+ } from '../sqlTokenEntities'
19
+
20
+ type Props = {
21
+ /** Capabilities offered in the form. Others render disabled. @default ['read'] */
22
+ capabilities?: SqlCapability[]
23
+ /** Command shown once a token is minted (what the user pastes). Defaults to a curl line. */
24
+ command?: (token: string) => string
25
+ }
26
+ let { capabilities: enabled = ['read'], command }: Props = $props()
27
+
28
+ const TTLS = Object.keys(SQL_TOKEN_TTLS) as SqlTokenTtl[]
29
+ const defaultCommand = (token: string) =>
30
+ `curl -X POST ${location.origin}/api/ff/sqlAdmin/exec -H "authorization: Bearer ${token}" -H "content-type: application/json" -d '{"args":["select 1"]}'`
31
+
32
+ let name = $state('')
33
+ let picked = $state<SqlCapability[]>(['read'])
34
+ let ttl = $state<SqlTokenTtl>('1h')
35
+ let minted = $state<string | null>(null)
36
+ let busy = $state(false)
37
+ let error = $state('')
38
+ let copied = $state(false)
39
+
40
+ let tokens = $state<SqlToken[]>([])
41
+ let calls = $state<SqlTokenCall[]>([])
42
+
43
+ async function refresh() {
44
+ ;[tokens, calls] = await Promise.all([
45
+ repo(SqlToken).find({ limit: 100 }),
46
+ repo(SqlTokenCall).find({ limit: 50 }),
47
+ ])
48
+ }
49
+
50
+ $effect(() => {
51
+ refresh()
52
+ })
53
+
54
+ async function mint(e: SubmitEvent) {
55
+ e.preventDefault()
56
+ busy = true
57
+ error = ''
58
+ try {
59
+ minted = await SqlAdminController.mintToken(name, picked, ttl)
60
+ name = ''
61
+ await refresh()
62
+ } catch (err) {
63
+ error = err instanceof Error ? err.message : String(err)
64
+ } finally {
65
+ busy = false
66
+ }
67
+ }
68
+
69
+ async function revoke(t: SqlToken) {
70
+ error = ''
71
+ try {
72
+ await repo(SqlToken).update(t.id, { revokedAt: new Date() })
73
+ await refresh()
74
+ } catch (err) {
75
+ error = err instanceof Error ? err.message : String(err)
76
+ }
77
+ }
78
+
79
+ function toggle(c: SqlCapability, on: boolean) {
80
+ picked = on ? [...new Set([...picked, c])] : picked.filter((x) => x !== c)
81
+ }
82
+
83
+ async function copy(text: string) {
84
+ try {
85
+ await navigator.clipboard.writeText(text)
86
+ copied = true
87
+ setTimeout(() => (copied = false), 1500)
88
+ } catch (e) {
89
+ log.error('copy failed', e)
90
+ }
91
+ }
92
+
93
+ const fmt = (d: Date | null) =>
94
+ d
95
+ ? d.toLocaleString(undefined, {
96
+ day: '2-digit',
97
+ month: 'short',
98
+ hour: '2-digit',
99
+ minute: '2-digit',
100
+ })
101
+ : '—'
102
+
103
+ const status = (t: SqlToken) =>
104
+ t.revokedAt ? 'revoked' : t.expiresAt.getTime() < Date.now() ? 'expired' : 'live'
105
+
106
+ const th = 'px-4 py-2 text-left text-xs font-medium tracking-wide text-muted-foreground uppercase'
107
+ const td = 'px-4 py-2 align-top'
108
+ const btn =
109
+ 'border border-border bg-secondary px-3 py-1.5 text-sm font-medium text-secondary-foreground hover:bg-accent hover:text-accent-foreground disabled:opacity-50'
110
+ </script>
111
+
112
+ <div class="border border-border bg-card text-card-foreground">
113
+ <header class="border-b border-border px-5 py-4">
114
+ <h2 class="text-lg font-semibold text-foreground">SQL Tokens</h2>
115
+ <p class="mt-1 text-sm text-muted-foreground">
116
+ A token is a bag of capabilities that acts as you, for a short while. Minting needs a live
117
+ session - a token can never mint or revoke a token. <code>read</code> runs inside a
118
+ <code>READ ONLY</code> transaction, one statement at a time. Every call is logged below (SQL text,
119
+ rows, ms, error - never the rows themselves). Revoking is immediate.
120
+ </p>
121
+ </header>
122
+
123
+ <div class="flex flex-col gap-6 p-5">
124
+ <form onsubmit={mint} class="flex flex-wrap items-end gap-4 text-sm">
125
+ <label class="flex flex-col gap-1">
126
+ <span class="text-xs text-muted-foreground">Name</span>
127
+ <input
128
+ bind:value={name}
129
+ required
130
+ placeholder="laptop · claude"
131
+ class="w-56 border border-input bg-background px-2 py-1.5 text-foreground focus:border-ring focus:outline-none"
132
+ />
133
+ </label>
134
+ <fieldset class="flex flex-col gap-1">
135
+ <legend class="text-xs text-muted-foreground">Capabilities</legend>
136
+ <div class="flex gap-4 py-1.5">
137
+ {#each SQL_CAPABILITIES as c (c)}
138
+ {@const disabled = !enabled.includes(c)}
139
+ <label
140
+ class="inline-flex items-center gap-2 select-none"
141
+ class:text-muted-foreground={disabled}
142
+ class:text-destructive={c === 'write' && picked.includes('write')}
143
+ >
144
+ <input
145
+ type="checkbox"
146
+ checked={picked.includes(c)}
147
+ {disabled}
148
+ onchange={(e) => toggle(c, e.currentTarget.checked)}
149
+ class={c === 'write' ? 'accent-destructive' : ''}
150
+ />
151
+ {c}{disabled ? ' (off)' : ''}
152
+ </label>
153
+ {/each}
154
+ </div>
155
+ </fieldset>
156
+ <label class="flex flex-col gap-1">
157
+ <span class="text-xs text-muted-foreground">Lifetime</span>
158
+ <select
159
+ bind:value={ttl}
160
+ class="border border-input bg-background px-2 py-1.5 text-foreground focus:border-ring focus:outline-none"
161
+ >
162
+ {#each TTLS as t (t)}
163
+ <option value={t}>{t}</option>
164
+ {/each}
165
+ </select>
166
+ </label>
167
+ <button
168
+ type="submit"
169
+ disabled={busy}
170
+ class="bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:opacity-90 disabled:opacity-50"
171
+ >
172
+ Mint
173
+ </button>
174
+ </form>
175
+
176
+ {#if error}
177
+ <pre
178
+ class="border border-destructive bg-destructive/10 p-3 text-sm text-destructive">{error}</pre>
179
+ {/if}
180
+
181
+ {#if minted}
182
+ {@const line = (command ?? defaultCommand)(minted)}
183
+ <div class="border border-primary bg-muted p-4 text-sm">
184
+ <p class="text-xs font-medium tracking-wide text-primary uppercase">Shown once - copy it now</p>
185
+ <code class="mt-2 block font-mono break-all select-all">{line}</code>
186
+ <div class="mt-3 flex gap-2">
187
+ <button type="button" class={btn} onclick={() => copy(line)}>
188
+ {copied ? 'Copied' : 'Copy'}
189
+ </button>
190
+ <button type="button" class={btn} onclick={() => (minted = null)}>Dismiss</button>
191
+ </div>
192
+ </div>
193
+ {/if}
194
+
195
+ <section>
196
+ <h3 class="mb-2 text-sm font-semibold text-foreground">Tokens</h3>
197
+ <div class="overflow-x-auto border border-border">
198
+ <table class="w-full text-sm">
199
+ <thead class="border-b border-border bg-muted">
200
+ <tr>
201
+ <th class={th}>Name</th>
202
+ <th class={th}>Hint</th>
203
+ <th class={th}>Capabilities</th>
204
+ <th class={th}>Created</th>
205
+ <th class={th}>Expires</th>
206
+ <th class={th}>Last used</th>
207
+ <th class={th}>Status</th>
208
+ <th class={th}></th>
209
+ </tr>
210
+ </thead>
211
+ <tbody>
212
+ {#each tokens as t (t.id)}
213
+ {@const s = status(t)}
214
+ <tr class="border-b border-border" class:text-muted-foreground={s !== 'live'}>
215
+ <td class={td}>{t.name}</td>
216
+ <td class="{td} font-mono">{t.hint}…</td>
217
+ <td class="{td} font-mono">{t.capabilities.join(' ')}</td>
218
+ <td class={td}>{fmt(t.createdAt)}</td>
219
+ <td class={td}>{fmt(t.expiresAt)}</td>
220
+ <td class={td}>{fmt(t.lastUsedAt)}</td>
221
+ <td class={td} class:text-primary={s === 'live'}>{s}</td>
222
+ <td class="{td} text-right">
223
+ {#if s === 'live'}
224
+ <button type="button" class={btn} onclick={() => revoke(t)}>Revoke</button>
225
+ {/if}
226
+ </td>
227
+ </tr>
228
+ {:else}
229
+ <tr><td colspan="8" class="px-4 py-6 text-muted-foreground">No token yet.</td></tr>
230
+ {/each}
231
+ </tbody>
232
+ </table>
233
+ </div>
234
+ </section>
235
+
236
+ <section>
237
+ <h3 class="mb-2 text-sm font-semibold text-foreground">Last calls</h3>
238
+ <div class="overflow-x-auto border border-border">
239
+ <table class="w-full text-sm">
240
+ <thead class="border-b border-border bg-muted">
241
+ <tr>
242
+ <th class={th}>When</th>
243
+ <th class={th}>Capability</th>
244
+ <th class={th}>SQL</th>
245
+ <th class={th}>Rows</th>
246
+ <th class={th}>ms</th>
247
+ <th class={th}>Error</th>
248
+ </tr>
249
+ </thead>
250
+ <tbody>
251
+ {#each calls as c (c.id)}
252
+ <tr class="border-b border-border">
253
+ <td class="{td} whitespace-nowrap">{fmt(c.ts)}</td>
254
+ <td class="{td} font-mono">{c.capability}</td>
255
+ <td class={td}><pre class="max-w-2xl font-mono whitespace-pre-wrap">{c.cmd}</pre></td>
256
+ <td class={td}>{c.rowCount}</td>
257
+ <td class={td}>{c.tookMs}</td>
258
+ <td class="{td} text-destructive">{c.error ?? ''}</td>
259
+ </tr>
260
+ {:else}
261
+ <tr><td colspan="6" class="px-4 py-6 text-muted-foreground">No call yet.</td></tr>
262
+ {/each}
263
+ </tbody>
264
+ </table>
265
+ </div>
266
+ </section>
267
+ </div>
268
+ </div>
@@ -0,0 +1,10 @@
1
+ import { type SqlCapability } from '../sqlTokenEntities';
2
+ type Props = {
3
+ /** Capabilities offered in the form. Others render disabled. @default ['read'] */
4
+ capabilities?: SqlCapability[];
5
+ /** Command shown once a token is minted (what the user pastes). Defaults to a curl line. */
6
+ command?: (token: string) => string;
7
+ };
8
+ declare const SqlTokens: import("svelte").Component<Props, {}, "">;
9
+ type SqlTokens = ReturnType<typeof SqlTokens>;
10
+ export default SqlTokens;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "firstly",
3
- "version": "0.9.3",
3
+ "version": "0.10.0",
4
4
  "type": "module",
5
5
  "description": "Firstly, an opinionated Remult setup!",
6
6
  "funding": "https://github.com/sponsors/jycouet",