firstly 0.9.4 → 0.10.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +16 -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 +80 -10
- package/esm/sqlAdmin/SqlAdminController.js +152 -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/sqlError.d.ts +6 -0
- package/esm/sqlAdmin/server/sqlError.js +132 -0
- package/esm/sqlAdmin/server/token.d.ts +5 -0
- package/esm/sqlAdmin/server/token.js +17 -0
- package/esm/sqlAdmin/sqlTokenEntities.d.ts +42 -0
- package/esm/sqlAdmin/sqlTokenEntities.js +138 -0
- package/esm/sqlAdmin/ui/SqlAdmin.svelte +2 -2
- package/esm/sqlAdmin/ui/SqlTokens.svelte +332 -0
- package/esm/sqlAdmin/ui/SqlTokens.svelte.d.ts +12 -0
- package/package.json +9 -2
|
@@ -0,0 +1,332 @@
|
|
|
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 full `ff-sql` line. */
|
|
24
|
+
command?: (token: string) => string
|
|
25
|
+
/** Remult api root, when the app moved it. @default '/api' */
|
|
26
|
+
apiPath?: string
|
|
27
|
+
}
|
|
28
|
+
let { capabilities: enabled = ['read'], command, apiPath = '/api' }: Props = $props()
|
|
29
|
+
|
|
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.
|
|
33
|
+
const defaultCommand = (token: string) =>
|
|
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(' ')
|
|
44
|
+
|
|
45
|
+
let name = $state('')
|
|
46
|
+
let picked = $state<SqlCapability[]>(['read'])
|
|
47
|
+
let ttl = $state<SqlTokenTtl>('1h')
|
|
48
|
+
let minted = $state<string | null>(null)
|
|
49
|
+
let busy = $state(false)
|
|
50
|
+
let error = $state('')
|
|
51
|
+
let copied = $state(false)
|
|
52
|
+
|
|
53
|
+
let tokens = $state<SqlToken[]>([])
|
|
54
|
+
let calls = $state<SqlTokenCall[]>([])
|
|
55
|
+
|
|
56
|
+
async function refresh() {
|
|
57
|
+
;[tokens, calls] = await Promise.all([
|
|
58
|
+
repo(SqlToken).find({ limit: 100 }),
|
|
59
|
+
repo(SqlTokenCall).find({ limit: 50 }),
|
|
60
|
+
])
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
$effect(() => {
|
|
64
|
+
refresh()
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
async function mint(e: SubmitEvent) {
|
|
68
|
+
e.preventDefault()
|
|
69
|
+
busy = true
|
|
70
|
+
error = ''
|
|
71
|
+
try {
|
|
72
|
+
minted = await SqlAdminController.mintToken(name, picked, ttl)
|
|
73
|
+
name = ''
|
|
74
|
+
await refresh()
|
|
75
|
+
} catch (err) {
|
|
76
|
+
error = err instanceof Error ? err.message : String(err)
|
|
77
|
+
} finally {
|
|
78
|
+
busy = false
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function revoke(t: SqlToken) {
|
|
83
|
+
error = ''
|
|
84
|
+
busy = true
|
|
85
|
+
try {
|
|
86
|
+
await repo(SqlToken).update(t.id, { revokedAt: new Date() })
|
|
87
|
+
await refresh()
|
|
88
|
+
} catch (err) {
|
|
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
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function toggle(c: SqlCapability, on: boolean) {
|
|
124
|
+
picked = on ? [...new Set([...picked, c])] : picked.filter((x) => x !== c)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function copy(text: string) {
|
|
128
|
+
try {
|
|
129
|
+
await navigator.clipboard.writeText(text)
|
|
130
|
+
copied = true
|
|
131
|
+
setTimeout(() => (copied = false), 1500)
|
|
132
|
+
} catch (e) {
|
|
133
|
+
log.error('copy failed', e)
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const fmt = (d: Date | null) =>
|
|
138
|
+
d
|
|
139
|
+
? d.toLocaleString(undefined, {
|
|
140
|
+
day: '2-digit',
|
|
141
|
+
month: 'short',
|
|
142
|
+
hour: '2-digit',
|
|
143
|
+
minute: '2-digit',
|
|
144
|
+
})
|
|
145
|
+
: '—'
|
|
146
|
+
|
|
147
|
+
const status = (t: SqlToken) =>
|
|
148
|
+
t.revokedAt ? 'revoked' : t.expiresAt.getTime() < Date.now() ? 'expired' : 'live'
|
|
149
|
+
|
|
150
|
+
const deadCount = $derived(tokens.filter((t) => status(t) !== 'live').length)
|
|
151
|
+
|
|
152
|
+
const th = 'px-4 py-2 text-left text-xs font-medium tracking-wide text-muted-foreground uppercase'
|
|
153
|
+
const td = 'px-4 py-2 align-top'
|
|
154
|
+
const btn =
|
|
155
|
+
'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'
|
|
156
|
+
</script>
|
|
157
|
+
|
|
158
|
+
<div class="border border-border bg-card text-card-foreground">
|
|
159
|
+
<header class="border-b border-border px-5 py-4">
|
|
160
|
+
<h2 class="text-lg font-semibold text-foreground">SQL Tokens</h2>
|
|
161
|
+
<p class="mt-1 text-sm text-muted-foreground">
|
|
162
|
+
A token is a bag of capabilities that acts as you, for a short while. Minting needs a live
|
|
163
|
+
session - a token can never mint or revoke a token. <code>read</code> runs inside a
|
|
164
|
+
<code>READ ONLY</code> transaction, one statement at a time. Every call is logged below (SQL text,
|
|
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.
|
|
167
|
+
</p>
|
|
168
|
+
</header>
|
|
169
|
+
|
|
170
|
+
<div class="flex flex-col gap-6 p-5">
|
|
171
|
+
<form onsubmit={mint} class="flex flex-wrap items-end gap-4 text-sm">
|
|
172
|
+
<label class="flex flex-col gap-1">
|
|
173
|
+
<span class="text-xs text-muted-foreground">Name (optional)</span>
|
|
174
|
+
<input
|
|
175
|
+
bind:value={name}
|
|
176
|
+
placeholder="auto: swift-otter-3f9"
|
|
177
|
+
class="w-56 border border-input bg-background px-2 py-1.5 text-foreground focus:border-ring focus:outline-none"
|
|
178
|
+
/>
|
|
179
|
+
</label>
|
|
180
|
+
<fieldset class="flex flex-col gap-1">
|
|
181
|
+
<legend class="text-xs text-muted-foreground">Capabilities</legend>
|
|
182
|
+
<div class="flex gap-4 py-1.5">
|
|
183
|
+
{#each SQL_CAPABILITIES as c (c)}
|
|
184
|
+
{@const disabled = !enabled.includes(c)}
|
|
185
|
+
<label
|
|
186
|
+
class="inline-flex items-center gap-2 select-none"
|
|
187
|
+
class:text-muted-foreground={disabled}
|
|
188
|
+
class:text-destructive={c === 'write' && picked.includes('write')}
|
|
189
|
+
>
|
|
190
|
+
<input
|
|
191
|
+
type="checkbox"
|
|
192
|
+
checked={picked.includes(c)}
|
|
193
|
+
{disabled}
|
|
194
|
+
onchange={(e) => toggle(c, e.currentTarget.checked)}
|
|
195
|
+
class={c === 'write' ? 'accent-destructive' : ''}
|
|
196
|
+
/>
|
|
197
|
+
{c}{disabled ? ' (off)' : ''}
|
|
198
|
+
</label>
|
|
199
|
+
{/each}
|
|
200
|
+
</div>
|
|
201
|
+
</fieldset>
|
|
202
|
+
<label class="flex flex-col gap-1">
|
|
203
|
+
<span class="text-xs text-muted-foreground">Lifetime</span>
|
|
204
|
+
<select
|
|
205
|
+
bind:value={ttl}
|
|
206
|
+
class="border border-input bg-background px-2 py-1.5 text-foreground focus:border-ring focus:outline-none"
|
|
207
|
+
>
|
|
208
|
+
{#each TTLS as t (t)}
|
|
209
|
+
<option value={t}>{t}</option>
|
|
210
|
+
{/each}
|
|
211
|
+
</select>
|
|
212
|
+
</label>
|
|
213
|
+
<button
|
|
214
|
+
type="submit"
|
|
215
|
+
disabled={busy}
|
|
216
|
+
class="bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:opacity-90 disabled:opacity-50"
|
|
217
|
+
>
|
|
218
|
+
Mint
|
|
219
|
+
</button>
|
|
220
|
+
</form>
|
|
221
|
+
|
|
222
|
+
{#if error}
|
|
223
|
+
<pre
|
|
224
|
+
class="border border-destructive bg-destructive/10 p-3 text-sm text-destructive">{error}</pre>
|
|
225
|
+
{/if}
|
|
226
|
+
|
|
227
|
+
{#if minted}
|
|
228
|
+
{@const line = (command ?? defaultCommand)(minted)}
|
|
229
|
+
<div class="border border-primary bg-muted p-4 text-sm">
|
|
230
|
+
<p class="text-xs font-medium tracking-wide text-primary uppercase">Shown once - copy it now</p>
|
|
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>
|
|
236
|
+
<div class="mt-3 flex gap-2">
|
|
237
|
+
<button type="button" class={btn} onclick={() => copy(line)}>
|
|
238
|
+
{copied ? 'Copied' : 'Copy'}
|
|
239
|
+
</button>
|
|
240
|
+
<button type="button" class={btn} onclick={() => (minted = null)}>Dismiss</button>
|
|
241
|
+
</div>
|
|
242
|
+
</div>
|
|
243
|
+
{/if}
|
|
244
|
+
|
|
245
|
+
<section>
|
|
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>
|
|
254
|
+
<div class="overflow-x-auto border border-border">
|
|
255
|
+
<table class="w-full text-sm">
|
|
256
|
+
<thead class="border-b border-border bg-muted">
|
|
257
|
+
<tr>
|
|
258
|
+
<th class={th}>Name</th>
|
|
259
|
+
<th class={th}>Hint</th>
|
|
260
|
+
<th class={th}>Capabilities</th>
|
|
261
|
+
<th class={th}>Created</th>
|
|
262
|
+
<th class={th}>Expires</th>
|
|
263
|
+
<th class={th}>Last used</th>
|
|
264
|
+
<th class={th}>Status</th>
|
|
265
|
+
<th class={th}></th>
|
|
266
|
+
</tr>
|
|
267
|
+
</thead>
|
|
268
|
+
<tbody>
|
|
269
|
+
{#each tokens as t (t.id)}
|
|
270
|
+
{@const s = status(t)}
|
|
271
|
+
<tr class="border-b border-border" class:text-muted-foreground={s !== 'live'}>
|
|
272
|
+
<td class={td}>{t.name}</td>
|
|
273
|
+
<td class="{td} font-mono">{t.hint}…</td>
|
|
274
|
+
<td class="{td} font-mono">{t.capabilities.join(' ')}</td>
|
|
275
|
+
<td class={td}>{fmt(t.createdAt)}</td>
|
|
276
|
+
<td class={td}>{fmt(t.expiresAt)}</td>
|
|
277
|
+
<td class={td}>{fmt(t.lastUsedAt)}</td>
|
|
278
|
+
<td class={td} class:text-primary={s === 'live'}>{s}</td>
|
|
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. -->
|
|
281
|
+
{#if s === 'live'}
|
|
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>
|
|
289
|
+
{/if}
|
|
290
|
+
</td>
|
|
291
|
+
</tr>
|
|
292
|
+
{:else}
|
|
293
|
+
<tr><td colspan="8" class="px-4 py-6 text-muted-foreground">No token yet.</td></tr>
|
|
294
|
+
{/each}
|
|
295
|
+
</tbody>
|
|
296
|
+
</table>
|
|
297
|
+
</div>
|
|
298
|
+
</section>
|
|
299
|
+
|
|
300
|
+
<section>
|
|
301
|
+
<h3 class="mb-2 text-sm font-semibold text-foreground">Last calls</h3>
|
|
302
|
+
<div class="overflow-x-auto border border-border">
|
|
303
|
+
<table class="w-full text-sm">
|
|
304
|
+
<thead class="border-b border-border bg-muted">
|
|
305
|
+
<tr>
|
|
306
|
+
<th class={th}>When</th>
|
|
307
|
+
<th class={th}>Capability</th>
|
|
308
|
+
<th class={th}>SQL</th>
|
|
309
|
+
<th class={th}>Rows</th>
|
|
310
|
+
<th class={th}>ms</th>
|
|
311
|
+
<th class={th}>Error</th>
|
|
312
|
+
</tr>
|
|
313
|
+
</thead>
|
|
314
|
+
<tbody>
|
|
315
|
+
{#each calls as c (c.id)}
|
|
316
|
+
<tr class="border-b border-border">
|
|
317
|
+
<td class="{td} whitespace-nowrap">{fmt(c.ts)}</td>
|
|
318
|
+
<td class="{td} font-mono">{c.capability}</td>
|
|
319
|
+
<td class={td}><pre class="max-w-2xl font-mono whitespace-pre-wrap">{c.cmd}</pre></td>
|
|
320
|
+
<td class={td}>{c.rowCount}</td>
|
|
321
|
+
<td class={td}>{c.tookMs}</td>
|
|
322
|
+
<td class="{td} text-destructive">{c.error ?? ''}</td>
|
|
323
|
+
</tr>
|
|
324
|
+
{:else}
|
|
325
|
+
<tr><td colspan="6" class="px-4 py-6 text-muted-foreground">No call yet.</td></tr>
|
|
326
|
+
{/each}
|
|
327
|
+
</tbody>
|
|
328
|
+
</table>
|
|
329
|
+
</div>
|
|
330
|
+
</section>
|
|
331
|
+
</div>
|
|
332
|
+
</div>
|
|
@@ -0,0 +1,12 @@
|
|
|
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 full `ff-sql` line. */
|
|
6
|
+
command?: (token: string) => string;
|
|
7
|
+
/** Remult api root, when the app moved it. @default '/api' */
|
|
8
|
+
apiPath?: string;
|
|
9
|
+
};
|
|
10
|
+
declare const SqlTokens: import("svelte").Component<Props, {}, "">;
|
|
11
|
+
type SqlTokens = ReturnType<typeof SqlTokens>;
|
|
12
|
+
export default SqlTokens;
|
package/package.json
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "firstly",
|
|
3
|
-
"version": "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"
|