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 +12 -0
- package/esm/feedback/FeedbackController.js +4 -1
- package/esm/sqlAdmin/SqlAdminController.d.ts +71 -10
- package/esm/sqlAdmin/SqlAdminController.js +117 -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/token.d.ts +3 -0
- package/esm/sqlAdmin/server/token.js +10 -0
- package/esm/sqlAdmin/sqlTokenEntities.d.ts +41 -0
- package/esm/sqlAdmin/sqlTokenEntities.js +120 -0
- package/esm/sqlAdmin/ui/SqlAdmin.svelte +2 -2
- package/esm/sqlAdmin/ui/SqlTokens.svelte +268 -0
- package/esm/sqlAdmin/ui/SqlTokens.svelte.d.ts +10 -0
- package/package.json +1 -1
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:
|
|
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
|
|
8
|
-
*
|
|
9
|
-
* (INSERT/UPDATE/DELETE/DDL).
|
|
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,
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
|
16
|
-
*
|
|
17
|
-
* (INSERT/UPDATE/DELETE/DDL).
|
|
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
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
33
|
-
return { rows, rowCount: rows.length, took };
|
|
117
|
+
return raw;
|
|
34
118
|
}
|
|
35
119
|
}
|
|
36
120
|
__decorate([
|
|
37
121
|
BackendMethod({
|
|
38
|
-
|
|
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
|
|
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);
|
package/esm/sqlAdmin/index.d.ts
CHANGED
|
@@ -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;
|
package/esm/sqlAdmin/index.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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,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;
|