firstly 0.10.0 → 0.10.2
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 +17 -0
- package/bin/args.d.ts +12 -0
- package/bin/args.js +66 -0
- package/bin/ff-sql.js +87 -0
- package/esm/cron/index.d.ts +1 -0
- package/esm/cron/index.js +1 -0
- package/esm/sqlAdmin/SqlAdminController.d.ts +10 -1
- package/esm/sqlAdmin/SqlAdminController.js +52 -17
- package/esm/sqlAdmin/server/sqlError.d.ts +6 -0
- package/esm/sqlAdmin/server/sqlError.js +132 -0
- package/esm/sqlAdmin/server/token.d.ts +2 -0
- package/esm/sqlAdmin/server/token.js +8 -1
- package/esm/sqlAdmin/sqlTokenEntities.d.ts +1 -0
- package/esm/sqlAdmin/sqlTokenEntities.js +19 -1
- package/esm/sqlAdmin/ui/SqlTokens.svelte +74 -10
- package/esm/sqlAdmin/ui/SqlTokens.svelte.d.ts +3 -1
- package/esm/svelte/ff.svelte.js +4 -3
- package/package.json +11 -4
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,22 @@
|
|
|
1
1
|
# firstly
|
|
2
2
|
|
|
3
|
+
## 0.10.2
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- [#366](https://github.com/jycouet/firstly/pull/366) [`2c661f6`](https://github.com/jycouet/firstly/commit/2c661f6474b8614733e5494e7deda3cd069aed9c) Thanks [@jycouet](https://github.com/jycouet)! - `ff` handles: read failed writes through `errorMessage()`. Remult rejects with a plain object, not
|
|
8
|
+
an `Error`, so `handle.error` used to end up as the string `[object Object]` on a 403.
|
|
9
|
+
|
|
10
|
+
## 0.10.1
|
|
11
|
+
|
|
12
|
+
### Patch Changes
|
|
13
|
+
|
|
14
|
+
- [#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.
|
|
15
|
+
|
|
16
|
+
- [#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`).
|
|
17
|
+
|
|
18
|
+
- [#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.
|
|
19
|
+
|
|
3
20
|
## 0.10.0
|
|
4
21
|
|
|
5
22
|
### Minor 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`)
|
package/esm/cron/index.d.ts
CHANGED
package/esm/cron/index.js
CHANGED
|
@@ -72,7 +72,16 @@ export declare class SqlAdminController {
|
|
|
72
72
|
private static execAsToken;
|
|
73
73
|
/**
|
|
74
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
|
|
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.
|
|
76
79
|
*/
|
|
77
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>;
|
|
78
87
|
}
|
|
@@ -10,6 +10,9 @@ import { Roles_SqlAdmin } from './Roles_SqlAdmin';
|
|
|
10
10
|
import { SQL_CAPABILITIES, SQL_TOKEN_TTLS, SqlToken, SqlTokenCall, } from './sqlTokenEntities';
|
|
11
11
|
export const SQL_ADMINS = [Roles_SqlAdmin.SqlAdmin_Admin, FF_Role.FF_Role_Admin];
|
|
12
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;
|
|
13
16
|
function getDb() {
|
|
14
17
|
return SqlAdminController.dp ?? SqlDatabase.getDb();
|
|
15
18
|
}
|
|
@@ -40,14 +43,20 @@ export class SqlAdminController {
|
|
|
40
43
|
}
|
|
41
44
|
static async run(cmd, capabilities) {
|
|
42
45
|
const db = getDb();
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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);
|
|
48
59
|
}
|
|
49
|
-
const { readOnlySql } = await import('./server/readOnlySql');
|
|
50
|
-
return readOnlySql(db, SqlAdminController.options.tokens?.pool ?? poolFrom(db), cmd);
|
|
51
60
|
}
|
|
52
61
|
static async execAsToken(token, cmd) {
|
|
53
62
|
const o = SqlAdminController.options.tokens;
|
|
@@ -71,15 +80,21 @@ export class SqlAdminController {
|
|
|
71
80
|
}
|
|
72
81
|
finally {
|
|
73
82
|
await repo(SqlTokenCall).insert(call);
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
+
}
|
|
78
90
|
}
|
|
79
91
|
}
|
|
80
92
|
/**
|
|
81
93
|
* 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
|
|
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.
|
|
83
98
|
*/
|
|
84
99
|
static async mintToken(name, capabilities, ttl) {
|
|
85
100
|
const o = SqlAdminController.options.tokens;
|
|
@@ -88,9 +103,6 @@ export class SqlAdminController {
|
|
|
88
103
|
const userId = remult.user?.id;
|
|
89
104
|
if (!userId)
|
|
90
105
|
throw new Error('Forbidden');
|
|
91
|
-
const clean = name.trim();
|
|
92
|
-
if (!clean)
|
|
93
|
-
throw new Error('Name is required');
|
|
94
106
|
const ttlMs = SQL_TOKEN_TTLS[ttl];
|
|
95
107
|
if (!ttlMs)
|
|
96
108
|
throw new Error('Unknown ttl');
|
|
@@ -103,11 +115,11 @@ export class SqlAdminController {
|
|
|
103
115
|
if (!o.capabilities.includes(c))
|
|
104
116
|
throw new Error(`${c} is not enabled`);
|
|
105
117
|
}
|
|
106
|
-
const { newRawToken, hashToken, tokenHint } = await import('./server/token');
|
|
118
|
+
const { newRawToken, hashToken, tokenHint, randomTokenName } = await import('./server/token');
|
|
107
119
|
const prefix = o.prefix ?? 'ffsql_';
|
|
108
120
|
const raw = newRawToken(prefix);
|
|
109
121
|
await repo(SqlToken).insert({
|
|
110
|
-
name:
|
|
122
|
+
name: name.trim() || randomTokenName(),
|
|
111
123
|
hint: tokenHint(raw, prefix),
|
|
112
124
|
tokenHash: hashToken(raw),
|
|
113
125
|
userId,
|
|
@@ -116,6 +128,23 @@ export class SqlAdminController {
|
|
|
116
128
|
});
|
|
117
129
|
return raw;
|
|
118
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.
|
|
135
|
+
*/
|
|
136
|
+
static async purgeTokens() {
|
|
137
|
+
const dead = await repo(SqlToken).find({
|
|
138
|
+
where: { $or: [{ revokedAt: { $ne: null } }, { expiresAt: { $lt: new Date() } }] },
|
|
139
|
+
});
|
|
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;
|
|
147
|
+
}
|
|
119
148
|
}
|
|
120
149
|
__decorate([
|
|
121
150
|
BackendMethod({
|
|
@@ -139,3 +168,9 @@ __decorate([
|
|
|
139
168
|
apiPrefix: 'ff/sqlAdmin',
|
|
140
169
|
})
|
|
141
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);
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { SqlDatabase } from 'remult';
|
|
2
|
+
/**
|
|
3
|
+
* Never throws and never hides the original message - the returned error is the
|
|
4
|
+
* one to rethrow, with the code and any hint appended.
|
|
5
|
+
*/
|
|
6
|
+
export declare function enrichSqlError(db: SqlDatabase, err: unknown, cmd?: string): Promise<unknown>;
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turns "column X does not exist" into something a caller can act on without a
|
|
3
|
+
* second round trip: Postgres' own HINT when it has one, otherwise the closest
|
|
4
|
+
* names taken from the catalog.
|
|
5
|
+
*
|
|
6
|
+
* Why it matters here more than in an app: the callers of `exec` are scripts
|
|
7
|
+
* and AIs writing SQL against a schema they cannot see, and every case observed
|
|
8
|
+
* in the call log was a misremembered identifier - quoted camelCase read as
|
|
9
|
+
* snake_case, a table named in the singular. Postgres already knows the answer
|
|
10
|
+
* (`Perhaps you meant "athleteEraEstimates.estMaxBpm"`) and the raw message
|
|
11
|
+
* throws it away.
|
|
12
|
+
*/
|
|
13
|
+
const RELATION_MISSING = /relation "([^"]+)" does not exist/;
|
|
14
|
+
const COLUMN_MISSING = /column "?([\w$.]+)"? does not exist/;
|
|
15
|
+
const NON_ALPHANUM = /[^a-z0-9]/g;
|
|
16
|
+
const SQLSTATE = /^[0-9A-Z]{5}$/;
|
|
17
|
+
const UNDEFINED_TABLE = '42P01';
|
|
18
|
+
const UNDEFINED_COLUMN = '42703';
|
|
19
|
+
/** SQLSTATE class 08: the connection is gone, so asking it about its catalog only adds a timeout. */
|
|
20
|
+
const CONNECTION_CLASS = '08';
|
|
21
|
+
const MAX_CANDIDATES = 3;
|
|
22
|
+
const MIN_MATCH_LENGTH = 3;
|
|
23
|
+
/** A caller looping on bad SQL must not turn each failure into a catalog scan. */
|
|
24
|
+
const CATALOG_TTL_MS = 10_000;
|
|
25
|
+
/** Comparison key: an unquoted identifier is folded to lowercase, and `_` is how the same name is spelled in snake_case. */
|
|
26
|
+
const norm = (s) => s.toLowerCase().replace(NON_ALPHANUM, '');
|
|
27
|
+
/** `a.analysisversion` and `public.users` are about their last part. */
|
|
28
|
+
const lastPart = (s) => s.split('.').at(-1);
|
|
29
|
+
/**
|
|
30
|
+
* Same name modulo case and underscores, then one name being the start of the
|
|
31
|
+
* other - which is what a singular/plural or a truncated guess looks like
|
|
32
|
+
* (`key_value` for `keyValues`). Deliberately not a typo distance: real misses
|
|
33
|
+
* are spellings of the right word, and Postgres already suggests actual typos
|
|
34
|
+
* through its own HINT.
|
|
35
|
+
*/
|
|
36
|
+
function closest(wanted, candidates) {
|
|
37
|
+
const target = norm(wanted);
|
|
38
|
+
if (target.length < MIN_MATCH_LENGTH)
|
|
39
|
+
return [];
|
|
40
|
+
const exact = candidates.filter((c) => norm(c.name) === target);
|
|
41
|
+
if (exact.length)
|
|
42
|
+
return exact.slice(0, MAX_CANDIDATES);
|
|
43
|
+
return candidates
|
|
44
|
+
.filter((c) => {
|
|
45
|
+
const n = norm(c.name);
|
|
46
|
+
return n.length >= MIN_MATCH_LENGTH && (n.startsWith(target) || target.startsWith(n));
|
|
47
|
+
})
|
|
48
|
+
.slice(0, MAX_CANDIDATES);
|
|
49
|
+
}
|
|
50
|
+
/** A column suggestion from a table the query never mentions is noise; keep those last. */
|
|
51
|
+
function preferMentioned(candidates, cmd) {
|
|
52
|
+
const haystack = norm(cmd);
|
|
53
|
+
const mentioned = candidates.filter((c) => c.table && haystack.includes(norm(c.table)));
|
|
54
|
+
return mentioned.length ? mentioned : candidates;
|
|
55
|
+
}
|
|
56
|
+
const catalogCache = new WeakMap();
|
|
57
|
+
async function catalog(db, sql) {
|
|
58
|
+
let perDb = catalogCache.get(db);
|
|
59
|
+
if (!perDb)
|
|
60
|
+
catalogCache.set(db, (perDb = new Map()));
|
|
61
|
+
const hit = perDb.get(sql);
|
|
62
|
+
if (hit && Date.now() - hit.at < CATALOG_TTL_MS)
|
|
63
|
+
return hit.rows;
|
|
64
|
+
let rows = [];
|
|
65
|
+
try {
|
|
66
|
+
rows = (await db.execute(sql)).rows ?? [];
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
// Not Postgres, no catalog access, connection gone: the plain error is still useful.
|
|
70
|
+
}
|
|
71
|
+
perDb.set(sql, { at: Date.now(), rows });
|
|
72
|
+
return rows;
|
|
73
|
+
}
|
|
74
|
+
async function suggest(db, err, cmd) {
|
|
75
|
+
const message = err.message ?? '';
|
|
76
|
+
if (err.code === UNDEFINED_TABLE) {
|
|
77
|
+
const wanted = RELATION_MISSING.exec(message)?.[1];
|
|
78
|
+
if (!wanted)
|
|
79
|
+
return [];
|
|
80
|
+
const rows = await catalog(db, `select table_name from information_schema.tables where table_schema not in ('pg_catalog', 'information_schema')`);
|
|
81
|
+
const found = closest(lastPart(wanted), rows.map((r) => ({ label: `"${r.table_name}"`, name: r.table_name })));
|
|
82
|
+
return found.map((c) => c.label);
|
|
83
|
+
}
|
|
84
|
+
if (err.code === UNDEFINED_COLUMN) {
|
|
85
|
+
const wanted = COLUMN_MISSING.exec(message)?.[1];
|
|
86
|
+
if (!wanted)
|
|
87
|
+
return [];
|
|
88
|
+
const rows = await catalog(db, `select table_name, column_name from information_schema.columns where table_schema not in ('pg_catalog', 'information_schema')`);
|
|
89
|
+
const found = closest(lastPart(wanted), rows.map((r) => ({
|
|
90
|
+
label: `"${r.table_name}"."${r.column_name}"`,
|
|
91
|
+
name: r.column_name,
|
|
92
|
+
table: r.table_name,
|
|
93
|
+
})));
|
|
94
|
+
return preferMentioned(found, cmd).map((c) => c.label);
|
|
95
|
+
}
|
|
96
|
+
return [];
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Never throws and never hides the original message - the returned error is the
|
|
100
|
+
* one to rethrow, with the code and any hint appended.
|
|
101
|
+
*/
|
|
102
|
+
export async function enrichSqlError(db, err, cmd = '') {
|
|
103
|
+
if (!(err instanceof Error))
|
|
104
|
+
return err;
|
|
105
|
+
const pg = err;
|
|
106
|
+
// A SQLSTATE, not any error that happens to carry a `code`: ENOTFOUND is too
|
|
107
|
+
// long to match, but EPIPE and EBUSY have the exact same shape.
|
|
108
|
+
if (!pg.code || !SQLSTATE.test(pg.code) || pg.code.startsWith(CONNECTION_CLASS))
|
|
109
|
+
return err;
|
|
110
|
+
if (pg.errno !== undefined || pg.syscall !== undefined)
|
|
111
|
+
return err;
|
|
112
|
+
const parts = [pg.message];
|
|
113
|
+
if (pg.hint)
|
|
114
|
+
parts.push(pg.hint);
|
|
115
|
+
else {
|
|
116
|
+
const found = await suggest(db, pg, cmd);
|
|
117
|
+
if (found.length)
|
|
118
|
+
parts.push(`Did you mean ${found.join(' or ')}?`);
|
|
119
|
+
}
|
|
120
|
+
if (pg.detail)
|
|
121
|
+
parts.push(pg.detail);
|
|
122
|
+
parts.push(`[${pg.code}${pg.position ? ` at ${pg.position}` : ''}]`);
|
|
123
|
+
const out = new Error(parts.join(' · '));
|
|
124
|
+
Object.assign(out, {
|
|
125
|
+
code: pg.code,
|
|
126
|
+
hint: pg.hint,
|
|
127
|
+
detail: pg.detail,
|
|
128
|
+
position: pg.position,
|
|
129
|
+
cause: err,
|
|
130
|
+
});
|
|
131
|
+
return out;
|
|
132
|
+
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
1
|
export declare function newRawToken(prefix: string): string;
|
|
2
2
|
export declare function hashToken(raw: string): string;
|
|
3
3
|
export declare function tokenHint(raw: string, prefix: string): string;
|
|
4
|
+
/** `swift-otter-3f9`: a name you can say out loud when asking "who is running this query?". */
|
|
5
|
+
export declare function randomTokenName(): string;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { createHash, randomBytes } from 'node:crypto';
|
|
1
|
+
import { createHash, randomBytes, randomInt } from 'node:crypto';
|
|
2
2
|
export function newRawToken(prefix) {
|
|
3
3
|
return prefix + randomBytes(32).toString('base64url');
|
|
4
4
|
}
|
|
@@ -8,3 +8,10 @@ export function hashToken(raw) {
|
|
|
8
8
|
export function tokenHint(raw, prefix) {
|
|
9
9
|
return raw.slice(0, prefix.length + 8);
|
|
10
10
|
}
|
|
11
|
+
const ADJECTIVES = 'amber bold brave calm clever cosmic crisp eager fuzzy gentle happy jolly keen lucky mellow neat nimble plucky quiet rapid rusty shiny silent sly snappy sunny swift tidy vivid witty zesty'.split(' ');
|
|
12
|
+
const ANIMALS = 'otter badger falcon heron ibex lynx marmot narwhal ocelot panda quokka raven seal tapir urchin viper walrus yak zebu gecko koala llama mantis newt puffin stoat toucan vole wombat'.split(' ');
|
|
13
|
+
/** `swift-otter-3f9`: a name you can say out loud when asking "who is running this query?". */
|
|
14
|
+
export function randomTokenName() {
|
|
15
|
+
const pick = (xs) => xs[randomInt(xs.length)];
|
|
16
|
+
return `${pick(ADJECTIVES)}-${pick(ANIMALS)}-${randomBytes(2).toString('hex').slice(0, 3)}`;
|
|
17
|
+
}
|
|
@@ -7,6 +7,7 @@ export declare const SQL_TOKEN_TTLS: {
|
|
|
7
7
|
readonly '7d': 604800000;
|
|
8
8
|
};
|
|
9
9
|
export type SqlTokenTtl = keyof typeof SQL_TOKEN_TTLS;
|
|
10
|
+
export declare const SQL_TOKEN_DELETE_LIVE = "Revoke a live token before deleting it";
|
|
10
11
|
export declare class SqlToken {
|
|
11
12
|
id: string;
|
|
12
13
|
name: string;
|
|
@@ -4,7 +4,7 @@ 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 { Entity, Fields } from 'remult';
|
|
7
|
+
import { Entity, Fields, repo } from 'remult';
|
|
8
8
|
import { FF_Role } from '../core/common';
|
|
9
9
|
import { Roles_SqlAdmin } from './Roles_SqlAdmin';
|
|
10
10
|
/** What a call may do: `read` runs inside a READ ONLY transaction (one statement), `write` runs SQL as is. */
|
|
@@ -14,6 +14,13 @@ const admins = [Roles_SqlAdmin.SqlAdmin_Admin, FF_Role.FF_Role_Admin];
|
|
|
14
14
|
// Minting is `SqlAdminController.mintToken` (the raw value is generated
|
|
15
15
|
// server-side, shown once, never stored). Revoking is the one update the API
|
|
16
16
|
// allows: set `revokedAt`; nothing else is writable and a revoke is final.
|
|
17
|
+
//
|
|
18
|
+
// Revoke and delete are not the same gesture: revoking is the kill switch and
|
|
19
|
+
// keeps the row plus its call log, which is the audit trail; deleting is
|
|
20
|
+
// cleanup and takes the calls with it - a call log pointing at a token nobody
|
|
21
|
+
// can name is noise, not history. So only a dead token can be deleted: killing
|
|
22
|
+
// a live one is always a revoke.
|
|
23
|
+
export const SQL_TOKEN_DELETE_LIVE = 'Revoke a live token before deleting it';
|
|
17
24
|
let SqlToken = class SqlToken {
|
|
18
25
|
id = '';
|
|
19
26
|
name = '';
|
|
@@ -61,14 +68,25 @@ __decorate([
|
|
|
61
68
|
SqlToken = __decorate([
|
|
62
69
|
Entity('_ff_sql_tokens', {
|
|
63
70
|
caption: 'FF Sql Tokens',
|
|
71
|
+
// Admins means a session: a bearer is only ever resolved for the exec path
|
|
72
|
+
// (`server/index.ts`), so a token reaching this entity is an anonymous request.
|
|
64
73
|
allowApiRead: admins,
|
|
65
74
|
allowApiUpdate: admins,
|
|
75
|
+
allowApiDelete: admins,
|
|
66
76
|
defaultOrderBy: { createdAt: 'desc' },
|
|
67
77
|
saving: (t, e) => {
|
|
68
78
|
if (!e.isNew && e.fields.revokedAt.valueChanged() && !t.revokedAt) {
|
|
69
79
|
throw new Error('A revoked token stays revoked');
|
|
70
80
|
}
|
|
71
81
|
},
|
|
82
|
+
deleting: (t) => {
|
|
83
|
+
if (isSqlTokenLive(t))
|
|
84
|
+
throw new Error(SQL_TOKEN_DELETE_LIVE);
|
|
85
|
+
},
|
|
86
|
+
// After, not before: a failed row delete must not have dropped the calls already.
|
|
87
|
+
deleted: async (t) => {
|
|
88
|
+
await repo(SqlTokenCall).deleteMany({ where: { tokenId: t.id } });
|
|
89
|
+
},
|
|
72
90
|
})
|
|
73
91
|
], SqlToken);
|
|
74
92
|
export { SqlToken };
|
|
@@ -20,14 +20,27 @@
|
|
|
20
20
|
type Props = {
|
|
21
21
|
/** Capabilities offered in the form. Others render disabled. @default ['read'] */
|
|
22
22
|
capabilities?: SqlCapability[]
|
|
23
|
-
/** Command shown once a token is minted (what the user pastes). Defaults to a
|
|
23
|
+
/** Command shown once a token is minted (what the user pastes). Defaults to a full `ff-sql` line. */
|
|
24
24
|
command?: (token: string) => string
|
|
25
|
+
/** Remult api root, when the app moved it. @default '/api' */
|
|
26
|
+
apiPath?: string
|
|
25
27
|
}
|
|
26
|
-
let { capabilities: enabled = ['read'], command }: Props = $props()
|
|
28
|
+
let { capabilities: enabled = ['read'], command, apiPath = '/api' }: Props = $props()
|
|
27
29
|
|
|
28
30
|
const TTLS = Object.keys(SQL_TOKEN_TTLS) as SqlTokenTtl[]
|
|
31
|
+
// Self-contained on purpose: every parameter is in the line, so the copy runs
|
|
32
|
+
// as is from any terminal - no env file, nothing to set up first.
|
|
29
33
|
const defaultCommand = (token: string) =>
|
|
30
|
-
|
|
34
|
+
[
|
|
35
|
+
`FF_SQL_TOKEN=${token}`,
|
|
36
|
+
// npx, not `pnpm exec`: the line must run wherever it is pasted.
|
|
37
|
+
'npx ff-sql',
|
|
38
|
+
`--origin=${location.origin}`,
|
|
39
|
+
apiPath === '/api' ? '' : `--api-path=${apiPath}`,
|
|
40
|
+
'"select 1"',
|
|
41
|
+
]
|
|
42
|
+
.filter(Boolean)
|
|
43
|
+
.join(' ')
|
|
31
44
|
|
|
32
45
|
let name = $state('')
|
|
33
46
|
let picked = $state<SqlCapability[]>(['read'])
|
|
@@ -68,11 +81,42 @@
|
|
|
68
81
|
|
|
69
82
|
async function revoke(t: SqlToken) {
|
|
70
83
|
error = ''
|
|
84
|
+
busy = true
|
|
71
85
|
try {
|
|
72
86
|
await repo(SqlToken).update(t.id, { revokedAt: new Date() })
|
|
73
87
|
await refresh()
|
|
74
88
|
} catch (err) {
|
|
75
89
|
error = err instanceof Error ? err.message : String(err)
|
|
90
|
+
} finally {
|
|
91
|
+
busy = false
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function remove(t: SqlToken) {
|
|
96
|
+
if (!confirm(`Delete "${t.name}" and its calls? The audit trail goes with it.`)) return
|
|
97
|
+
error = ''
|
|
98
|
+
busy = true
|
|
99
|
+
try {
|
|
100
|
+
await repo(SqlToken).delete(t)
|
|
101
|
+
await refresh()
|
|
102
|
+
} catch (err) {
|
|
103
|
+
error = err instanceof Error ? err.message : String(err)
|
|
104
|
+
} finally {
|
|
105
|
+
busy = false
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function purge() {
|
|
110
|
+
if (!confirm('Delete every expired or revoked token, with their calls?')) return
|
|
111
|
+
error = ''
|
|
112
|
+
busy = true
|
|
113
|
+
try {
|
|
114
|
+
await SqlAdminController.purgeTokens()
|
|
115
|
+
await refresh()
|
|
116
|
+
} catch (err) {
|
|
117
|
+
error = err instanceof Error ? err.message : String(err)
|
|
118
|
+
} finally {
|
|
119
|
+
busy = false
|
|
76
120
|
}
|
|
77
121
|
}
|
|
78
122
|
|
|
@@ -103,6 +147,8 @@
|
|
|
103
147
|
const status = (t: SqlToken) =>
|
|
104
148
|
t.revokedAt ? 'revoked' : t.expiresAt.getTime() < Date.now() ? 'expired' : 'live'
|
|
105
149
|
|
|
150
|
+
const deadCount = $derived(tokens.filter((t) => status(t) !== 'live').length)
|
|
151
|
+
|
|
106
152
|
const th = 'px-4 py-2 text-left text-xs font-medium tracking-wide text-muted-foreground uppercase'
|
|
107
153
|
const td = 'px-4 py-2 align-top'
|
|
108
154
|
const btn =
|
|
@@ -116,18 +162,18 @@
|
|
|
116
162
|
A token is a bag of capabilities that acts as you, for a short while. Minting needs a live
|
|
117
163
|
session - a token can never mint or revoke a token. <code>read</code> runs inside a
|
|
118
164
|
<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
|
|
165
|
+
rows, ms, error - never the rows themselves). Revoking is immediate and keeps the trail; a dead token
|
|
166
|
+
can then be deleted, which drops its calls too.
|
|
120
167
|
</p>
|
|
121
168
|
</header>
|
|
122
169
|
|
|
123
170
|
<div class="flex flex-col gap-6 p-5">
|
|
124
171
|
<form onsubmit={mint} class="flex flex-wrap items-end gap-4 text-sm">
|
|
125
172
|
<label class="flex flex-col gap-1">
|
|
126
|
-
<span class="text-xs text-muted-foreground">Name</span>
|
|
173
|
+
<span class="text-xs text-muted-foreground">Name (optional)</span>
|
|
127
174
|
<input
|
|
128
175
|
bind:value={name}
|
|
129
|
-
|
|
130
|
-
placeholder="laptop · claude"
|
|
176
|
+
placeholder="auto: swift-otter-3f9"
|
|
131
177
|
class="w-56 border border-input bg-background px-2 py-1.5 text-foreground focus:border-ring focus:outline-none"
|
|
132
178
|
/>
|
|
133
179
|
</label>
|
|
@@ -183,6 +229,10 @@
|
|
|
183
229
|
<div class="border border-primary bg-muted p-4 text-sm">
|
|
184
230
|
<p class="text-xs font-medium tracking-wide text-primary uppercase">Shown once - copy it now</p>
|
|
185
231
|
<code class="mt-2 block font-mono break-all select-all">{line}</code>
|
|
232
|
+
<p class="mt-2 text-xs text-muted-foreground">
|
|
233
|
+
The token is in the line: paste it with a leading space, or put it in an env file, to keep it
|
|
234
|
+
out of your shell history.
|
|
235
|
+
</p>
|
|
186
236
|
<div class="mt-3 flex gap-2">
|
|
187
237
|
<button type="button" class={btn} onclick={() => copy(line)}>
|
|
188
238
|
{copied ? 'Copied' : 'Copy'}
|
|
@@ -193,7 +243,14 @@
|
|
|
193
243
|
{/if}
|
|
194
244
|
|
|
195
245
|
<section>
|
|
196
|
-
<
|
|
246
|
+
<div class="mb-2 flex items-center justify-between">
|
|
247
|
+
<h3 class="text-sm font-semibold text-foreground">Tokens</h3>
|
|
248
|
+
{#if deadCount}
|
|
249
|
+
<button type="button" class={btn} disabled={busy} onclick={purge}>
|
|
250
|
+
Delete {deadCount} dead
|
|
251
|
+
</button>
|
|
252
|
+
{/if}
|
|
253
|
+
</div>
|
|
197
254
|
<div class="overflow-x-auto border border-border">
|
|
198
255
|
<table class="w-full text-sm">
|
|
199
256
|
<thead class="border-b border-border bg-muted">
|
|
@@ -219,9 +276,16 @@
|
|
|
219
276
|
<td class={td}>{fmt(t.expiresAt)}</td>
|
|
220
277
|
<td class={td}>{fmt(t.lastUsedAt)}</td>
|
|
221
278
|
<td class={td} class:text-primary={s === 'live'}>{s}</td>
|
|
222
|
-
<td class="{td} text-right">
|
|
279
|
+
<td class="{td} space-x-2 text-right whitespace-nowrap">
|
|
280
|
+
<!-- Revoke is the kill switch, delete is cleanup: a live token can only be revoked. -->
|
|
223
281
|
{#if s === 'live'}
|
|
224
|
-
<button type="button" class={btn} onclick={() => revoke(t)}>
|
|
282
|
+
<button type="button" class={btn} disabled={busy} onclick={() => revoke(t)}>
|
|
283
|
+
Revoke
|
|
284
|
+
</button>
|
|
285
|
+
{:else}
|
|
286
|
+
<button type="button" class={btn} disabled={busy} onclick={() => remove(t)}>
|
|
287
|
+
Delete
|
|
288
|
+
</button>
|
|
225
289
|
{/if}
|
|
226
290
|
</td>
|
|
227
291
|
</tr>
|
|
@@ -2,8 +2,10 @@ import { type SqlCapability } from '../sqlTokenEntities';
|
|
|
2
2
|
type Props = {
|
|
3
3
|
/** Capabilities offered in the form. Others render disabled. @default ['read'] */
|
|
4
4
|
capabilities?: SqlCapability[];
|
|
5
|
-
/** Command shown once a token is minted (what the user pastes). Defaults to a
|
|
5
|
+
/** Command shown once a token is minted (what the user pastes). Defaults to a full `ff-sql` line. */
|
|
6
6
|
command?: (token: string) => string;
|
|
7
|
+
/** Remult api root, when the app moved it. @default '/api' */
|
|
8
|
+
apiPath?: string;
|
|
7
9
|
};
|
|
8
10
|
declare const SqlTokens: import("svelte").Component<Props, {}, "">;
|
|
9
11
|
type SqlTokens = ReturnType<typeof SqlTokens>;
|
package/esm/svelte/ff.svelte.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { repo as remultRepo, } from 'remult';
|
|
2
|
+
import { errorMessage } from '../core/helper.js';
|
|
2
3
|
import { dialog } from './dialog.svelte.js';
|
|
3
4
|
/** Classify a thrown read error into an {@link FF_Issue} (best-effort status sniffing). */
|
|
4
5
|
function toIssue(e) {
|
|
@@ -70,7 +71,7 @@ class FF_RepoHandle {
|
|
|
70
71
|
this.#fireItems();
|
|
71
72
|
},
|
|
72
73
|
error: (e) => {
|
|
73
|
-
this.error =
|
|
74
|
+
this.error = errorMessage(e);
|
|
74
75
|
this.loading.init = false;
|
|
75
76
|
this.#fireIssue(toIssue(e));
|
|
76
77
|
},
|
|
@@ -144,7 +145,7 @@ class FF_RepoHandle {
|
|
|
144
145
|
}
|
|
145
146
|
catch (e) {
|
|
146
147
|
if (seq === this.#seq) {
|
|
147
|
-
this.error =
|
|
148
|
+
this.error = errorMessage(e);
|
|
148
149
|
this.#fireIssue(toIssue(e));
|
|
149
150
|
}
|
|
150
151
|
}
|
|
@@ -280,7 +281,7 @@ class FF_RepoHandle {
|
|
|
280
281
|
return res;
|
|
281
282
|
}
|
|
282
283
|
catch (e) {
|
|
283
|
-
this.error =
|
|
284
|
+
this.error = errorMessage(e);
|
|
284
285
|
throw e;
|
|
285
286
|
}
|
|
286
287
|
finally {
|
package/package.json
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "firstly",
|
|
3
|
-
"version": "0.10.
|
|
3
|
+
"version": "0.10.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Firstly, an opinionated Remult setup!",
|
|
6
|
+
"bin": {
|
|
7
|
+
"ff-sql": "./bin/ff-sql.js"
|
|
8
|
+
},
|
|
6
9
|
"funding": "https://github.com/sponsors/jycouet",
|
|
7
10
|
"homepage": "https://firstly.fun/",
|
|
8
11
|
"repository": {
|
|
@@ -15,7 +18,7 @@
|
|
|
15
18
|
"node": ">=18"
|
|
16
19
|
},
|
|
17
20
|
"peerDependencies": {
|
|
18
|
-
"@sveltejs/kit": ">=1.0.0 <3.0.0",
|
|
21
|
+
"@sveltejs/kit": ">=1.0.0 <4.0.0 || ^3.0.0-next.0",
|
|
19
22
|
"remult": ">=3.3.7",
|
|
20
23
|
"svelte": ">=5"
|
|
21
24
|
},
|
|
@@ -39,8 +42,8 @@
|
|
|
39
42
|
"cron": "4.4.0",
|
|
40
43
|
"esm-env": "1.2.2",
|
|
41
44
|
"nodemailer": "8.0.5",
|
|
42
|
-
"svelte-sonner": "1.
|
|
43
|
-
"tailwind-merge": "3.
|
|
45
|
+
"svelte-sonner": "1.2.1",
|
|
46
|
+
"tailwind-merge": "3.7.0",
|
|
44
47
|
"tailwindcss": "4.3.0",
|
|
45
48
|
"vite-plugin-kit-routes": "1.0.6",
|
|
46
49
|
"vite-plugin-stripper": "0.10.4"
|
|
@@ -77,6 +80,10 @@
|
|
|
77
80
|
"types": "./esm/changeLog/server/index.d.ts",
|
|
78
81
|
"default": "./esm/changeLog/server/index.js"
|
|
79
82
|
},
|
|
83
|
+
"./cron": {
|
|
84
|
+
"types": "./esm/cron/index.d.ts",
|
|
85
|
+
"default": "./esm/cron/index.js"
|
|
86
|
+
},
|
|
80
87
|
"./cron/server": {
|
|
81
88
|
"types": "./esm/cron/server/index.d.ts",
|
|
82
89
|
"default": "./esm/cron/server/index.js"
|