firstly 0.10.0 → 0.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # firstly
2
2
 
3
+ ## 0.10.1
4
+
5
+ ### Patch Changes
6
+
7
+ - [#365](https://github.com/jycouet/firstly/pull/365) [`cf68997`](https://github.com/jycouet/firstly/commit/cf68997ca89672c2026ea502d197a670b942f4c0) Thanks [@jycouet](https://github.com/jycouet)! - Expose `firstly/cron` (`Roles_Cron`, `Cron`) so client code can import them, like `firstly/changeLog` already does.
8
+
9
+ - [#364](https://github.com/jycouet/firstly/pull/364) [`fcd660e`](https://github.com/jycouet/firstly/commit/fcd660ef5a8677f2dad6d4c8bc1017ae2fe46574) Thanks [@jycouet](https://github.com/jycouet)! - Accept SvelteKit 3 as a peer, prereleases included (`3.0.0-next.x`).
10
+
11
+ - [#362](https://github.com/jycouet/firstly/pull/362) [`993486d`](https://github.com/jycouet/firstly/commit/993486dd25b5c06def4874a3b3f95440bc79a845) Thanks [@jycouet](https://github.com/jycouet)! - sqlAdmin: failed queries now carry Postgres' HINT (or the closest catalog names) and the SQLSTATE, so `column a.analysisversion does not exist` comes back with `Did you mean "activities"."analysisVersion"?`. New `ff-sql` bin to run token SQL from a terminal (`--origin=`, `--api-path=`, `--json`, stdin, `FF_SQL_TOKEN`/`FF_SQL_ORIGIN`); the mint screen now copies the full command, so there is nothing to set up. Tokens can be named automatically (`swift-otter-3f9`); a dead token can be deleted - one by one, or every one of them via `purgeTokens()` - which takes its call log with it, while a revoke keeps it.
12
+
3
13
  ## 0.10.0
4
14
 
5
15
  ### 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`)
@@ -1,4 +1,5 @@
1
1
  import { Log } from '@kitql/helpers';
2
+ export { Cron } from './Cron';
2
3
  export { Roles_Cron } from './Roles_Cron';
3
4
  export declare const key = "cron";
4
5
  export declare const log: Log;
package/esm/cron/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { Log } from '@kitql/helpers';
2
+ export { Cron } from './Cron';
2
3
  export { Roles_Cron } from './Roles_Cron';
3
4
  export const key = 'cron';
4
5
  export const log = new Log(key);
@@ -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
- // 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 };
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
- const days = o.callLogRetentionDays ?? 30;
75
- await repo(SqlTokenCall).deleteMany({
76
- where: { ts: { $lt: new Date(Date.now() - days * 86_400_000) } },
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: clean,
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 curl line. */
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
- `curl -X POST ${location.origin}/api/ff/sqlAdmin/exec -H "authorization: Bearer ${token}" -H "content-type: application/json" -d '{"args":["select 1"]}'`
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
- required
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
- <h3 class="mb-2 text-sm font-semibold text-foreground">Tokens</h3>
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)}>Revoke</button>
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 curl line. */
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/package.json CHANGED
@@ -1,8 +1,11 @@
1
1
  {
2
2
  "name": "firstly",
3
- "version": "0.10.0",
3
+ "version": "0.10.1",
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
  },
@@ -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"