golem-kit 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/README.md +8 -5
  3. package/docs/agents.md +64 -0
  4. package/docs/app-backend.md +259 -0
  5. package/docs/architecture.md +93 -0
  6. package/docs/builder.md +15 -0
  7. package/docs/knowledge.md +35 -0
  8. package/docs/local-cli.md +19 -12
  9. package/docs/source-development.md +31 -0
  10. package/index.html +9 -0
  11. package/package.json +24 -5
  12. package/src/backend/accounts.ts +287 -0
  13. package/src/backend/app.ts +269 -0
  14. package/src/backend/files.ts +68 -0
  15. package/src/backend/http.ts +276 -0
  16. package/src/backend/index.ts +10 -0
  17. package/src/backend/jobs.ts +302 -0
  18. package/src/backend/jsonl.ts +87 -0
  19. package/src/backend/knowledge.ts +264 -0
  20. package/src/backend/model.ts +129 -0
  21. package/src/backend/rules.ts +53 -0
  22. package/src/backend/sqlite.ts +73 -0
  23. package/src/backend/views.ts +216 -0
  24. package/src/brain.ts +94 -0
  25. package/src/browser/adapters.ts +229 -53
  26. package/src/browser/ansi.ts +104 -0
  27. package/src/browser/app.d.ts +5 -2
  28. package/src/browser/app.tsx +167 -39
  29. package/src/browser/groups.tsx +29 -0
  30. package/src/browser/main.tsx +1 -0
  31. package/src/browser/panekeys.ts +34 -0
  32. package/src/browser/sources.tsx +113 -0
  33. package/src/browser/styles.css +36 -0
  34. package/src/browser/terminal.tsx +89 -0
  35. package/src/browser-build.ts +20 -7
  36. package/src/chat.ts +74 -0
  37. package/src/cli.ts +85 -13
  38. package/src/client.ts +205 -0
  39. package/src/config.ts +139 -5
  40. package/src/dev-server.ts +336 -39
  41. package/src/entry.mjs +19 -0
  42. package/src/eslint.mjs +55 -0
  43. package/src/operations.ts +169 -0
  44. package/src/runtime/assistant.ts +141 -0
  45. package/src/runtime/discovery.ts +13 -7
  46. package/src/runtime/harness/agent-status.js +388 -0
  47. package/src/runtime/harness/claude-tmux.js +573 -0
  48. package/src/runtime/harness/codex-notify.js +95 -0
  49. package/src/runtime/harness/codex-tmux.js +292 -0
  50. package/src/runtime/harness/fake.js +430 -0
  51. package/src/runtime/harness/package.json +1 -0
  52. package/src/runtime/harness/port.js +208 -0
  53. package/src/runtime/harness/tmux-session.js +556 -0
  54. package/src/runtime/harness/tmux.js +285 -0
  55. package/src/runtime/harness/turnend-hook.js +105 -0
  56. package/src/runtime/session.ts +171 -34
  57. package/src/runtime/tmux.ts +173 -0
  58. package/src/runtime/tool-names.ts +19 -0
  59. package/src/source-mode.ts +56 -0
  60. package/vite.config.ts +2 -4
  61. package/src/runtime/codex.ts +0 -119
@@ -7,6 +7,16 @@ deduplicating React with the Golem checkout. It also loads the UI checkout's ins
7
7
  `@tailwindcss/vite` plugin and adds the checkout as a Tailwind `@source`, so utility classes in
8
8
  edited UI components are compiled and scanned.
9
9
 
10
+ ## Pinned golem-ui
11
+
12
+ The published `golem-ui@0.1.1` predates the `Brain` component and `ChatMessage.sources`. This
13
+ checkout is developed against golem-ui commit `e26476d` (MNC-187 Shell chrome: the menu row, the
14
+ settings dropdown, the chat toggle; `initialTab` is gone); until that ships as a package version, run
15
+ with `GOLEM_UI_SOURCE` pointing at a checkout of it to get the Brain reader, the source chips, the
16
+ slash-command picker, a chat-less normal mode, and the Brain, Admin and Builder controls. With 0.1.1
17
+ installed the shell still builds with the old chrome and none of those controls, and the Brain panel
18
+ says what is missing.
19
+
10
20
  ## Clean checkout
11
21
 
12
22
  ```sh
@@ -58,8 +68,29 @@ editing the isolated UI checkout. Ordinary source mode expects the normal Messag
58
68
  No package scripts or lockfiles need to change when switching modes. `golem-ui` itself uses Vite
59
69
  and its package build is `pnpm build`; source mode consumes its TypeScript entry point directly.
60
70
 
71
+ ## An app against the checkouts
72
+
61
73
  For generated apps, `GOLEM_SOURCE=/path/to/golem /path/to/app/golem dev` opts into
62
74
  the framework checkout while preserving the app cwd and lockfile. Omit
63
75
  `GOLEM_SOURCE` to use the installed pinned `golem-kit`; combine it with
64
76
  `GOLEM_UI_SOURCE=/path/to/golem-ui` when developing both checkouts. Source-mode
65
77
  builds print each source path and short git revision.
78
+
79
+ Source mode covers the app's own code too, not only the shell's. An app that imports
80
+ `golem-kit/server` in `src/server/index.ts`, `golem-kit/client` or `golem-ui` in `src/app.tsx`
81
+ resolves those imports from the checkouts, in all three places the app's code is read:
82
+
83
+ - the **app typecheck** (`tsc` over `src/app.tsx`, `golem.config.ts` and `src/server/index.ts`),
84
+ through a generated tsconfig whose `paths` point at the checkouts;
85
+ - the **browser bundle**, through Vite aliases;
86
+ - the **app server bundle**, where `golem-kit/server` resolves to the checkout's file and stays
87
+ external, so the app and the running server share one module instance.
88
+
89
+ Nothing is written into the app folder and `node_modules` is never edited: the app keeps the
90
+ published `golem-kit` and `golem-ui` it installed, and drops back to them the moment the
91
+ variables are absent. `golem-ui` is read from its `src/`, so its checkout needs no `pnpm build`.
92
+
93
+ An exported `GOLEM_SOURCE` also decides which `entry.mjs` the app's `./golem` script launches, so
94
+ source mode works even when the installed `golem-kit` is older than the checkout. `GOLEM_SOURCE`
95
+ set in `.env.local` is read by node instead, which needs the installed `golem-kit` to be recent
96
+ enough to have `src/entry.mjs`.
package/index.html CHANGED
@@ -4,6 +4,15 @@
4
4
  <meta charset="UTF-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
6
  <title>Golem</title>
7
+ <!-- Theme before first paint: the Shell sets it again in an effect, too late to stop a white frame on reload.
8
+ Same key and default as the Shell: the saved choice, else the OS. -->
9
+ <script>
10
+ try { document.documentElement.dataset.golemTheme = localStorage.getItem('golem-shell-theme') ?? (matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light') } catch { document.documentElement.dataset.golemTheme = 'light' }
11
+ </script>
12
+ <style>
13
+ html { background: #f4f6f9; color-scheme: light; }
14
+ html[data-golem-theme='dark'] { background: #0e131a; color-scheme: dark; } /* golem-ui Shell: --chat-bg */
15
+ </style>
7
16
  </head>
8
17
  <body>
9
18
  <div id="root"></div>
package/package.json CHANGED
@@ -1,12 +1,25 @@
1
1
  {
2
2
  "name": "golem-kit",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "Starter kit and local CLI for Golem applications.",
5
5
  "type": "module",
6
+ "exports": {
7
+ "./client": "./src/client.ts",
8
+ "./eslint": "./src/eslint.mjs",
9
+ "./operations": "./src/operations.ts",
10
+ "./server": "./src/backend/index.ts",
11
+ "./*": "./*"
12
+ },
6
13
  "files": [
14
+ "CHANGELOG.md",
7
15
  "README.md",
8
16
  "assets/golem-mascot.png",
9
17
  "docs/local-cli.md",
18
+ "docs/architecture.md",
19
+ "docs/builder.md",
20
+ "docs/app-backend.md",
21
+ "docs/knowledge.md",
22
+ "docs/agents.md",
10
23
  "docs/source-development.md",
11
24
  "golem.config.ts",
12
25
  "index.html",
@@ -34,15 +47,21 @@
34
47
  "test": "node --test --test-concurrency=1 test/*.test.mjs"
35
48
  },
36
49
  "dependencies": {
50
+ "@anthropic-ai/sdk": "0.127.0",
51
+ "@babel/core": "8.0.6",
52
+ "@babel/eslint-parser": "8.0.6",
37
53
  "@types/node": "22.20.3",
38
54
  "@types/react": "19.2.2",
39
55
  "@types/react-dom": "19.2.1",
40
56
  "@vitejs/plugin-react": "5.0.4",
57
+ "croner": "10.0.1",
58
+ "eslint": "10.11.0",
59
+ "golem-ui": "0.2.0",
60
+ "react": "19.2.0",
61
+ "react-dom": "19.2.0",
62
+ "tsx": "4.23.13",
41
63
  "typescript": "7.0.2",
42
64
  "vite": "7.1.9",
43
- "tsx": "4.23.13",
44
- "golem-ui": "0.1.1",
45
- "react": "19.2.0",
46
- "react-dom": "19.2.0"
65
+ "zod": "4.6.5"
47
66
  }
48
67
  }
@@ -0,0 +1,287 @@
1
+ import { createHash, randomBytes, scrypt, timingSafeEqual } from 'node:crypto'
2
+ import { EventEmitter } from 'node:events'
3
+ import { isPlain, type AccountsConfig } from '../config.ts'
4
+ import {
5
+ AppError, ForbiddenError, InvalidError, NotFoundError, RecordRefusedError, UnauthorizedError, validId, z,
6
+ type Principal, type RecordStore, type Row,
7
+ } from '../operations.ts'
8
+
9
+ /** Reserved collections: the builtin records operations refuse `_` names, and nothing here reaches the change stream. */
10
+ export const ACCOUNTS = '_accounts'
11
+ export const SESSIONS = '_sessions'
12
+ export const INVITES = '_invites'
13
+
14
+ const day = 86_400_000
15
+ const sessionLifetime = 14 * day
16
+ const inviteLifetime = 7 * day
17
+ const maxFailures = 5
18
+ const lockout = 15 * 60_000
19
+
20
+ export type Member = { id: string; name: string; email: string; roles: string[]; groups: string[] }
21
+ type Account = Row & Member & { password: string }
22
+ type User = Extract<Principal, { kind: 'user' }>
23
+
24
+ export class RateLimitedError extends AppError {
25
+ override name = 'RateLimitedError'
26
+ override status = 429
27
+ }
28
+
29
+ const email = z.string().trim().toLowerCase().min(3).max(254).refine((value) => value.includes('@'), 'must be an email address')
30
+ const password = z.string().min(8, 'Use at least 8 characters.').max(256)
31
+ const group = z.string().regex(/^[A-Za-z0-9_.-]{1,64}$/)
32
+ export const inputs = {
33
+ signIn: z.object({ email, password: z.string().max(256) }),
34
+ signUp: z.object({ name: z.string().trim().min(1).max(120), email, password, invite: z.string().max(128).optional() }),
35
+ invite: z.object({ role: z.string() }),
36
+ role: z.object({ role: z.string() }),
37
+ groups: z.object({ groups: z.array(group).max(64) }),
38
+ }
39
+
40
+ function parse<T>(schema: z.ZodType<T>, input: unknown): T {
41
+ const parsed = schema.safeParse(input)
42
+ if (!parsed.success) throw new InvalidError(z.prettifyError(parsed.error))
43
+ return parsed.data
44
+ }
45
+
46
+ /**
47
+ * Local accounts: scrypt password hashes, server-side sessions named by the SHA-256 of a random
48
+ * cookie token, single-use invites, and the principal every request and agent call acts as.
49
+ */
50
+ export function createAccounts(records: RecordStore, config: AccountsConfig) {
51
+ /** Emits `change` with an account id whenever its sessions, roles, groups or existence change. */
52
+ const changes = new EventEmitter().setMaxListeners(0)
53
+ const failures = new Map<string, { count: number; until: number }>()
54
+ // Sign-up checks the email, claims the invite and creates the account as one step, one at a time.
55
+ let signingUp: Promise<unknown> = Promise.resolve()
56
+ const managing = new Set(config.roles.filter((role) => role.manages).map((role) => role.id))
57
+ const roleIds = new Set(config.roles.map((role) => role.id))
58
+ const digest = (token: string) => createHash('sha256').update(token).digest('base64url')
59
+ const manages = (principal: Principal) => principal.kind === 'user' && principal.roles.some((role) => managing.has(role))
60
+ const member = ({ id, name, email, roles, groups }: Account): Member => ({ id, name, email, roles, groups })
61
+ const user = (account: Account, session?: string): User => ({ kind: 'user', id: account.id, name: account.name, roles: account.roles, groups: account.groups, ...(session ? { session } : {}) })
62
+
63
+ async function all(): Promise<Account[]> {
64
+ const found: Account[] = []
65
+ let cursor: string | null = null
66
+ do {
67
+ const page: Awaited<ReturnType<RecordStore['list']>> = await records.list(ACCOUNTS, { cursor, limit: 500 })
68
+ found.push(...page.rows as Account[])
69
+ cursor = page.nextCursor
70
+ } while (cursor)
71
+ return found
72
+ }
73
+
74
+ /** The live session with this id and its account, or nothing once it is signed out, expired or its account removed. */
75
+ async function live(sessionId: string): Promise<{ session: Row; account: Account } | undefined> {
76
+ if (!/^[A-Za-z0-9_-]{43}$/.test(sessionId)) return undefined
77
+ const session = await records.get(SESSIONS, sessionId)
78
+ if (!session) return undefined
79
+ if (Date.parse(String(session.expiresAt)) <= Date.now()) {
80
+ await records.remove(SESSIONS, session.id).catch(() => {})
81
+ return undefined
82
+ }
83
+ const account = await records.get(ACCOUNTS, String(session.userId)) as Account | null
84
+ return account ? { session, account } : undefined
85
+ }
86
+
87
+ async function startSession(account: Account): Promise<{ user: Member; token: string }> {
88
+ const token = randomBytes(32).toString('base64url')
89
+ await records.create(SESSIONS, { id: digest(token), userId: account.id, expiresAt: new Date(Date.now() + sessionLifetime).toISOString() })
90
+ return { user: member(account), token }
91
+ }
92
+
93
+ function requireManager(actor: Principal): void {
94
+ if (actor.kind !== 'user') throw new UnauthorizedError('Sign in first.')
95
+ if (!manages(actor)) throw new ForbiddenError('Only an admin can manage members.')
96
+ }
97
+
98
+ async function target(id: string): Promise<Account> {
99
+ const account = await records.get(ACCOUNTS, validId(id)) as Account | null
100
+ if (!account) throw new NotFoundError('That member no longer exists.')
101
+ return account
102
+ }
103
+
104
+ async function keepsAManager(changed: Account, roles: string[] | null): Promise<void> {
105
+ if (!changed.roles.some((role) => managing.has(role)) || roles?.some((role) => managing.has(role))) return
106
+ const managers = (await all()).filter((account) => account.roles.some((role) => managing.has(role)))
107
+ if (managers.length <= 1) throw new RecordRefusedError('Keep at least one member who manages accounts.')
108
+ }
109
+
110
+ function throttle(keys: string[]): void {
111
+ const now = Date.now()
112
+ for (const [key, entry] of failures) if (entry.until <= now) failures.delete(key)
113
+ if (keys.some((key) => (failures.get(key)?.count ?? 0) >= maxFailures)) throw new RateLimitedError('Too many attempts. Try again in 15 minutes.')
114
+ }
115
+
116
+ function fail(keys: string[]): void {
117
+ for (const key of keys) {
118
+ const entry = failures.get(key) ?? { count: 0, until: 0 }
119
+ failures.set(key, { count: entry.count + 1, until: Date.now() + lockout })
120
+ }
121
+ }
122
+
123
+ /** For server-owned work that acts for an account (a scheduled job): its current roles and groups, no session. */
124
+ async function resolveAccount(id: string): Promise<User> {
125
+ const account = await records.get(ACCOUNTS, validId(id)) as Account | null
126
+ if (!account) throw new ForbiddenError(`Account ${id} no longer exists`)
127
+ return user(account)
128
+ }
129
+
130
+ async function createAccount(name: string, email: string, hashed: string, invite: string | undefined): Promise<Account> {
131
+ if ((await records.list(ACCOUNTS, { filter: { email }, limit: 1 })).rows.length) throw new InvalidError('That email already has an account. Sign in instead.')
132
+ // Without an invite, only a role that neither manages nor builds, whatever the role order.
133
+ let role = config.roles.find(isPlain)?.id
134
+ if (invite) {
135
+ const found = /^[A-Za-z0-9_-]{43}$/.test(invite) ? await records.get(INVITES, digest(invite)) : null
136
+ if (!found || Date.parse(String(found.expiresAt)) <= Date.now()) throw new InvalidError('That invite link has expired or was already used.')
137
+ // Removing first claims the invite: a second sign-up on the same link finds it gone.
138
+ await records.remove(INVITES, found.id).catch(() => { throw new InvalidError('That invite link has expired or was already used.') })
139
+ role = String(found.role)
140
+ } else if (!config.allowSignUp || !role) {
141
+ throw new ForbiddenError('This app is invite-only. Ask an admin for an invite link.')
142
+ }
143
+ return await records.create(ACCOUNTS, { email, name, password: hashed, roles: [role], groups: [] }) as Account
144
+ }
145
+
146
+ async function mintInvite(role: string, origin: string, lifetime: number): Promise<string> {
147
+ if (!roleIds.has(role)) throw new InvalidError(`Unknown role: ${role}`)
148
+ const token = randomBytes(32).toString('base64url')
149
+ await records.create(INVITES, { id: digest(token), role, expiresAt: new Date(Date.now() + lifetime).toISOString() })
150
+ return `${origin}/?invite=${token}`
151
+ }
152
+
153
+ return {
154
+ config,
155
+ changes,
156
+ manages,
157
+ /** Build access is its own permission: a managing role or the `builder` role. */
158
+ canBuild: (principal: Principal) => manages(principal) || (principal.kind === 'user' && principal.roles.includes('builder')),
159
+
160
+ /** The principal behind a session cookie token; anonymous when there is none or it is no longer live. */
161
+ async fromToken(token: string | undefined): Promise<Principal> {
162
+ const found = token ? await live(digest(token)) : undefined
163
+ return found ? user(found.account, found.session.id) : { kind: 'anonymous' }
164
+ },
165
+
166
+ /**
167
+ * Re-reads a principal from stored accounts before trusted work runs. A session-bound principal
168
+ * stays valid only while that session is live; a signed-in identity never degrades to anonymous.
169
+ */
170
+ async refresh(principal: Principal): Promise<Principal> {
171
+ if (principal.kind !== 'user') return principal
172
+ if (!principal.session) return resolveAccount(principal.id)
173
+ const found = await live(principal.session)
174
+ if (!found || found.account.id !== principal.id) throw new UnauthorizedError('This session has ended. Sign in again.')
175
+ return user(found.account, found.session.id)
176
+ },
177
+
178
+ resolveAccount,
179
+
180
+ /** True while any live session of this account exists. */
181
+ async signedIn(id: string): Promise<boolean> {
182
+ const sessions = await records.list(SESSIONS, { filter: { userId: id }, limit: 500 })
183
+ return sessions.rows.some((session) => Date.parse(String(session.expiresAt)) > Date.now())
184
+ },
185
+
186
+ async me(principal: Principal): Promise<Member | null> {
187
+ if (principal.kind !== 'user') return null
188
+ const account = await records.get(ACCOUNTS, principal.id) as Account | null
189
+ return account ? member(account) : null
190
+ },
191
+
192
+ async signIn(input: unknown, client: string): Promise<{ user: Member; token: string }> {
193
+ const { email, password } = parse(inputs.signIn, input)
194
+ const keys = [`email:${email}`, `client:${client}`]
195
+ throttle(keys)
196
+ const account = (await records.list(ACCOUNTS, { filter: { email }, limit: 1 })).rows[0] as Account | undefined
197
+ // Hash even for an unknown address so timing does not reveal which addresses have accounts.
198
+ if (!(await verify(password, account?.password ?? unknownAccount))) {
199
+ fail(keys)
200
+ throw new InvalidError('That email or password is not right.')
201
+ }
202
+ failures.delete(keys[0])
203
+ return startSession(account!)
204
+ },
205
+
206
+ async signUp(input: unknown): Promise<{ user: Member; token: string }> {
207
+ const { name, email, password, invite } = parse(inputs.signUp, input)
208
+ const hashed = await hash(password)
209
+ const step = signingUp.then(() => createAccount(name, email, hashed, invite))
210
+ signingUp = step.catch(() => {})
211
+ return startSession(await step)
212
+ },
213
+
214
+ async signOut(principal: Principal): Promise<void> {
215
+ if (principal.kind !== 'user' || !principal.session) return
216
+ await records.remove(SESSIONS, principal.session).catch(() => {})
217
+ changes.emit('change', principal.id)
218
+ },
219
+
220
+ async members(actor: Principal): Promise<Member[]> {
221
+ requireManager(actor)
222
+ return (await all()).map(member)
223
+ },
224
+
225
+ async invite(actor: Principal, input: unknown, origin: string): Promise<string> {
226
+ requireManager(actor)
227
+ return mintInvite(parse(inputs.invite, input).role, origin, inviteLifetime)
228
+ },
229
+
230
+ async setRole(actor: Principal, id: string, input: unknown): Promise<void> {
231
+ requireManager(actor)
232
+ const { role } = parse(inputs.role, input)
233
+ if (!roleIds.has(role)) throw new InvalidError(`Unknown role: ${role}`)
234
+ const account = await target(id)
235
+ await keepsAManager(account, [role])
236
+ await records.update(ACCOUNTS, account.id, { roles: [role] })
237
+ changes.emit('change', account.id)
238
+ },
239
+
240
+ async setGroups(actor: Principal, id: string, input: unknown): Promise<void> {
241
+ requireManager(actor)
242
+ const account = await target(id)
243
+ await records.update(ACCOUNTS, account.id, { groups: [...new Set(parse(inputs.groups, input).groups)] })
244
+ changes.emit('change', account.id)
245
+ },
246
+
247
+ async remove(actor: Principal, id: string): Promise<void> {
248
+ requireManager(actor)
249
+ const account = await target(id)
250
+ await keepsAManager(account, null)
251
+ const sessions = await records.list(SESSIONS, { filter: { userId: account.id }, limit: 500 })
252
+ for (const session of sessions.rows) await records.remove(SESSIONS, session.id).catch(() => {})
253
+ await records.remove(ACCOUNTS, account.id)
254
+ changes.emit('change', account.id)
255
+ },
256
+
257
+ /**
258
+ * A one-use invite for the first managing role, for the terminal that starts the server:
259
+ * only while no account exists at all, or when the operator explicitly asks for recovery.
260
+ */
261
+ async managerInvite(origin: string, recover: boolean): Promise<string | undefined> {
262
+ if (!recover && (await records.list(ACCOUNTS, { limit: 1 })).rows.length) return undefined
263
+ return mintInvite(config.roles.find((role) => role.manages)!.id, origin, day)
264
+ },
265
+ }
266
+ }
267
+
268
+ export type Accounts = ReturnType<typeof createAccounts>
269
+
270
+ const keyLength = 64
271
+ const derive = (secret: string, salt: Buffer) => new Promise<Buffer>((resolve, reject) => scrypt(secret, salt, keyLength, (error, key) => error ? reject(error) : resolve(key)))
272
+
273
+ async function hash(secret: string): Promise<string> {
274
+ const salt = randomBytes(16)
275
+ return `scrypt$${salt.toString('base64')}$${(await derive(secret, salt)).toString('base64')}`
276
+ }
277
+
278
+ async function verify(secret: string, stored: string): Promise<boolean> {
279
+ const [scheme, salt, expected] = stored.split('$')
280
+ if (scheme !== 'scrypt' || !salt || !expected) return false
281
+ const actual = await derive(secret, Buffer.from(salt, 'base64'))
282
+ const wanted = Buffer.from(expected, 'base64')
283
+ return actual.length === wanted.length && timingSafeEqual(actual, wanted) && stored !== unknownAccount
284
+ }
285
+
286
+ // A well-formed hash no password matches; the last check in verify() refuses it outright.
287
+ const unknownAccount = `scrypt$${randomBytes(16).toString('base64')}$${randomBytes(keyLength).toString('base64')}`