golem-kit 0.1.0 → 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 +11 -6
  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 +31 -15
  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 +91 -17
  38. package/src/client.ts +205 -0
  39. package/src/config.ts +169 -0
  40. package/src/dev-server.ts +339 -38
  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
package/docs/local-cli.md CHANGED
@@ -3,15 +3,17 @@
3
3
  Use Node.js >=22.18.0 (native TypeScript execution) and pnpm 10.28.2.
4
4
  Run `pnpm install`, then `./golem help`. The executable wrapper resolves the
5
5
  project-local CLI relative to itself, including when invoked from another directory.
6
- The browser shell uses the local Vite build and the published `golem-ui` package.
6
+ The browser shell uses the local Vite build and the published `golem-ui` package. Before every
7
+ command, the wrapper optionally loads the app-local `.env.local` using Node's standard dotenv-file
8
+ support. Values already exported in the shell win; a missing file is ignored.
7
9
 
8
10
  ## Source resolution seam
9
11
 
10
- The root wrapper explicitly selects `src/cli.ts` relative to the wrapper's own
12
+ The root wrapper explicitly selects `src/entry.mjs` relative to the wrapper's own
11
13
  directory. This is the sole CLI entrypoint resolution point today; there is no
12
- package-name lookup or resolution configuration. Generated app wrappers add an
13
- explicit `GOLEM_SOURCE=/path/to/golem` opt-in for running a framework checkout
14
- while keeping the app cwd. Unset it to use the installed pinned package.
14
+ package-name lookup or resolution configuration. Generated app wrappers load `.env.local` before
15
+ selecting `GOLEM_SOURCE`, so a local checkout can be selected while keeping the app cwd. An invalid
16
+ source path fails clearly; unset it to use the installed pinned package.
15
17
 
16
18
  The CLI imports `./dev-server.ts` relative to the wrapper. The server serves built `dist/`
17
19
  files and does not resolve packages or depend on
@@ -23,19 +25,29 @@ boundary so a hot-source workflow can select the CLI without changing HTTP start
23
25
  | Command | Behavior | Exit status |
24
26
  | --- | --- | --- |
25
27
  | `./golem help` (or `./golem`) | List all commands | 0 |
26
- | `./golem dev` | Refresh the browser build, then serve it at `http://127.0.0.1:3000/` until SIGINT/SIGTERM | 0 on clean shutdown; 1 on startup failure |
28
+ | `./golem dev` | Refresh the browser build, then serve it at the `golem.config.ts` address (default `http://127.0.0.1:3000/`) until SIGINT/SIGTERM | 0 on clean shutdown; 1 on startup failure |
27
29
  | `./golem build` | Build the browser shell into `dist/` | 0 |
30
+ | `./golem lint` | Check the app's architecture rules with ESLint and `eslint.config.mjs` ([architecture guide](architecture.md)) | 0 clean; 1 violations or missing config |
28
31
  | `./golem doctor` | Report local shell and backend readiness | 0 |
29
32
 
30
33
  Unknown commands and extra arguments exit 2. Built assets are served directly; extensionless browser
31
34
  routes fall back to `index.html`, while missing assets return 404. Malformed URLs return 400. The
32
- server binds to loopback only. Port 3000 must be free.
35
+ default server binds to loopback on port 3000. To use another local port or a
36
+ specific tailnet address, export optional root settings from `golem.config.ts`:
37
+
38
+ ```ts
39
+ export default { title: 'Golem', host: '100.64.0.10', port: 3000 }
40
+ ```
41
+
42
+ `host` must be a nonempty string and `port` an integer from 1 through 65535.
43
+ Configuration, build, and bind failures are printed by `./golem dev`. Restart
44
+ the server after changing these settings.
33
45
  `doctor` succeeding means its report ran, not that the full product is ready.
34
46
 
35
- `src/dev-server.ts` exports `startDevServer(port = 3000)`, resolving to a listening
47
+ `src/dev-server.ts` exports `startDevServer(port = 3000, backend, stateDirectory, host = '127.0.0.1')`, resolving to a listening
36
48
  Node HTTP server. It refreshes the browser build before listening; the CLI owns signal handling and output. `src/browser/app.tsx` is the
37
49
  composition boundary: anonymous identity and browser navigation are explicit host adapters, while
38
- the chat adapter connects explicit browser build-mode sessions to the local Codex runtime.
50
+ the chat adapter connects explicit browser build-mode sessions to the chosen local Claude Code or Codex runtime.
39
51
 
40
52
  Check types with `pnpm exec tsc --noEmit`; run the CLI/HTTP smoke check with
41
53
  `node --test --test-concurrency=1 test/*.mjs` (serial because the CLI test intentionally removes
@@ -43,18 +55,22 @@ and rebuilds the shared `dist/` directory; requires port 3000).
43
55
 
44
56
  ## Generated projects
45
57
 
46
- `golem-kit init` creates `package.json`, `golem.config.ts`, `src/app.tsx`,
47
- `docs/domain.md`, and an executable `./golem`. Normal initialization writes the
48
- pinned npm dependency `golem-kit@<framework version>` and installs it with pnpm.
58
+ `golem-kit init` creates `package.json`, `golem.config.ts`, `eslint.config.mjs`, `src/app.tsx`,
59
+ `docs/domain.md` (the app's DNA template), small `AGENTS.md` and `CLAUDE.md` pointers to the
60
+ installed framework guide, and an executable `./golem`. Normal initialization writes the
61
+ pinned npm dependency `golem-kit@<framework version>` and installs it with pnpm, along with
62
+ the exact `golem-ui` version golem-kit uses so app code can import its components directly.
49
63
  For local packed-tarball acceptance only, set `GOLEM_KIT_TARBALL=/path/to/golem-kit.tgz`.
50
64
  An existing package is supported only when it already declares `golem-kit`; its
51
- metadata is preserved. Other nonempty destinations are refused.
65
+ metadata is preserved. It adds `.golem/` and `.env.local` to an existing `.gitignore` without
66
+ removing its content. Other nonempty destinations are refused.
52
67
 
53
68
  Two-checkout development:
54
69
 
55
70
  ```sh
56
- GOLEM_SOURCE=/path/to/golem /path/to/app/golem build
57
- GOLEM_SOURCE=/path/to/golem /path/to/app/golem dev
71
+ printf '%s\n' 'GOLEM_SOURCE=/path/to/golem' > /path/to/app/.env.local
72
+ /path/to/app/golem build
73
+ /path/to/app/golem dev
58
74
  ```
59
75
 
60
76
  The wrapper changes into the app first, so `src/` and `dist/` remain app-owned.
@@ -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.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
- "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')}`