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.
- package/CHANGELOG.md +31 -0
- package/README.md +8 -5
- package/docs/agents.md +64 -0
- package/docs/app-backend.md +259 -0
- package/docs/architecture.md +93 -0
- package/docs/builder.md +15 -0
- package/docs/knowledge.md +35 -0
- package/docs/local-cli.md +19 -12
- package/docs/source-development.md +31 -0
- package/index.html +9 -0
- package/package.json +24 -5
- package/src/backend/accounts.ts +287 -0
- package/src/backend/app.ts +269 -0
- package/src/backend/files.ts +68 -0
- package/src/backend/http.ts +276 -0
- package/src/backend/index.ts +10 -0
- package/src/backend/jobs.ts +302 -0
- package/src/backend/jsonl.ts +87 -0
- package/src/backend/knowledge.ts +264 -0
- package/src/backend/model.ts +129 -0
- package/src/backend/rules.ts +53 -0
- package/src/backend/sqlite.ts +73 -0
- package/src/backend/views.ts +216 -0
- package/src/brain.ts +94 -0
- package/src/browser/adapters.ts +229 -53
- package/src/browser/ansi.ts +104 -0
- package/src/browser/app.d.ts +5 -2
- package/src/browser/app.tsx +167 -39
- package/src/browser/groups.tsx +29 -0
- package/src/browser/main.tsx +1 -0
- package/src/browser/panekeys.ts +34 -0
- package/src/browser/sources.tsx +113 -0
- package/src/browser/styles.css +36 -0
- package/src/browser/terminal.tsx +89 -0
- package/src/browser-build.ts +20 -7
- package/src/chat.ts +74 -0
- package/src/cli.ts +85 -13
- package/src/client.ts +205 -0
- package/src/config.ts +139 -5
- package/src/dev-server.ts +336 -39
- package/src/entry.mjs +19 -0
- package/src/eslint.mjs +55 -0
- package/src/operations.ts +169 -0
- package/src/runtime/assistant.ts +141 -0
- package/src/runtime/discovery.ts +13 -7
- package/src/runtime/harness/agent-status.js +388 -0
- package/src/runtime/harness/claude-tmux.js +573 -0
- package/src/runtime/harness/codex-notify.js +95 -0
- package/src/runtime/harness/codex-tmux.js +292 -0
- package/src/runtime/harness/fake.js +430 -0
- package/src/runtime/harness/package.json +1 -0
- package/src/runtime/harness/port.js +208 -0
- package/src/runtime/harness/tmux-session.js +556 -0
- package/src/runtime/harness/tmux.js +285 -0
- package/src/runtime/harness/turnend-hook.js +105 -0
- package/src/runtime/session.ts +171 -34
- package/src/runtime/tmux.ts +173 -0
- package/src/runtime/tool-names.ts +19 -0
- package/src/source-mode.ts +56 -0
- package/vite.config.ts +2 -4
- 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.
|
|
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
|
-
"
|
|
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')}`
|