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.
@@ -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.9.4",
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"