golem-kit 0.2.4 → 0.2.5

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 CHANGED
@@ -1,5 +1,9 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.5
4
+
5
+ - A manager resets a member's password from the member list instead of removing and re-inviting them. **Reset password** mints a one-use link, good for 24 hours, that opens the sign-in card in choose-a-new-password mode; the account keeps its id, email, roles and groups, and every session it had is revoked. New: `accounts.reset`, `POST /api/auth/members/<id>/reset` and `POST /api/auth/password`, on golem-ui 0.2.1.
6
+
3
7
  ## 0.2.4
4
8
 
5
9
  - Chat and builder no longer fight over one Stop hook: the turn-end relay asks tmux which window it ran in instead of trusting the key baked in when it was installed, so a chat message sent after the builder was opened gets its reply and the composer unlocks. `installHooks` also keeps exactly one entry for itself, matched by script name, so a source-pin change between releases replaces it instead of stacking another.
@@ -175,7 +175,7 @@ The roles above are the default. A role with `manages: true` may invite, change
175
175
  - **guests: true**: signed-out callers run as `anonymous` through `authorize`. The default `authorize` allows everything, so write one that refuses what guests may not do.
176
176
  - **Policy** stays in `authorize`: check `principal.roles`, `principal.groups` and `record`. Without an `authorize`, every signed-in member may do everything.
177
177
  - **Build mode** needs a signed-in member who may build; `/api/runtime` and every `/api/sessions` route answer 401 or 403 to anyone else. A build conversation belongs to the member who started it. Conversations saved before accounts were enabled are visible to managers only. Losing build access, or signing out everywhere, interrupts a running build turn.
178
- - **Managing**: managers get a Admin item in the shell menu row: golem-ui's member list for invites, roles and removal, plus a groups editor. Apps can use `identity` and `setGroups` from `golem-kit/client`.
178
+ - **Managing**: managers get a Admin item in the shell menu row: golem-ui's member list for invites, roles, password resets and removal, plus a groups editor. Apps can use `identity` and `setGroups` from `golem-kit/client`.
179
179
  - **Identity in the UI**: `identity` from `golem-kit/client` is golem-ui's `IdentityAdapter`. Pass it to `Auth.Guard` or `Timeline`. It exposes nothing a server rule trusts.
180
180
 
181
181
  ### First admin and recovery
@@ -194,7 +194,8 @@ When the store has no account yet, `./golem dev` prints a one-use admin invite l
194
194
  - Accounts, sessions and invites live in reserved `_` collections. The records operations refuse those collections, and the change stream never names them.
195
195
  - Five failed sign-ins lock that email, and separately that client address, for 15 minutes.
196
196
  - Browser writes must come from this origin. Without `origin`, the `Origin` header must match the `Host` header. Behind a proxy, set `origin` to the public origin; then it is the only one accepted. Forwarding headers such as `X-Forwarded-For` are never read, so behind a proxy the per-address lockout counts the proxy's address.
197
- - This protects one app's data between people who use it. It is not a hosted identity provider: there is no email verification, password reset (a manager removes and re-invites), external sign-in or two-factor. Server code and anyone with the data directory can read everything.
197
+ - A member who forgets their password gets a new one from a manager, not by email: **Reset password** in the member list mints a one-use link, good for 24 hours, that opens the sign-in card in "choose a new password" mode. The account keeps its id, email, roles and groups — so whatever the app tied to that id survives — and every session it had is revoked, so an open browser elsewhere is signed out.
198
+ - This protects one app's data between people who use it. It is not a hosted identity provider: there is no email verification, self-service password reset, external sign-in or two-factor. Server code and anyone with the data directory can read everything.
198
199
 
199
200
  ## Jobs
200
201
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "golem-kit",
3
- "version": "0.2.4",
3
+ "version": "0.2.5",
4
4
  "description": "Starter kit and local CLI for Golem applications.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -56,7 +56,7 @@
56
56
  "@vitejs/plugin-react": "5.0.4",
57
57
  "croner": "10.0.1",
58
58
  "eslint": "10.11.0",
59
- "golem-ui": "0.2.0",
59
+ "golem-ui": "0.2.1",
60
60
  "react": "19.2.0",
61
61
  "react-dom": "19.2.0",
62
62
  "tsx": "4.23.13",
@@ -33,6 +33,7 @@ export const inputs = {
33
33
  signIn: z.object({ email, password: z.string().max(256) }),
34
34
  signUp: z.object({ name: z.string().trim().min(1).max(120), email, password, invite: z.string().max(128).optional() }),
35
35
  invite: z.object({ role: z.string() }),
36
+ setPassword: z.object({ reset: z.string().max(128), password }),
36
37
  role: z.object({ role: z.string() }),
37
38
  groups: z.object({ groups: z.array(group).max(64) }),
38
39
  }
@@ -127,27 +128,44 @@ export function createAccounts(records: RecordStore, config: AccountsConfig) {
127
128
  return user(account)
128
129
  }
129
130
 
131
+ /** Spends a one-use link's token and returns the row it carries; `kind` only names it for the reader. */
132
+ async function claim(token: string, kind: 'invite' | 'reset'): Promise<Row> {
133
+ const spent = () => new InvalidError(`That ${kind} link has expired or was already used.`)
134
+ const found = /^[A-Za-z0-9_-]{43}$/.test(token) ? await records.get(INVITES, digest(token)) : null
135
+ if (!found || Date.parse(String(found.expiresAt)) <= Date.now()) throw spent()
136
+ // Removing first claims the link: a second use finds it gone.
137
+ await records.remove(INVITES, found.id).catch(() => { throw spent() })
138
+ if (!found[kind === 'invite' ? 'role' : 'account']) throw spent()
139
+ return found
140
+ }
141
+
130
142
  async function createAccount(name: string, email: string, hashed: string, invite: string | undefined): Promise<Account> {
131
143
  if ((await records.list(ACCOUNTS, { filter: { email }, limit: 1 })).rows.length) throw new InvalidError('That email already has an account. Sign in instead.')
132
144
  // Without an invite, only a role that neither manages nor builds, whatever the role order.
133
145
  let role = config.roles.find(isPlain)?.id
134
146
  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)
147
+ role = String((await claim(invite, 'invite')).role)
140
148
  } else if (!config.allowSignUp || !role) {
141
149
  throw new ForbiddenError('This app is invite-only. Ask an admin for an invite link.')
142
150
  }
143
151
  return await records.create(ACCOUNTS, { email, name, password: hashed, roles: [role], groups: [] }) as Account
144
152
  }
145
153
 
154
+ /** A one-use link: an invite carries the role it grants, a reset carries the account it belongs to. */
155
+ async function mintLink(carries: { role: string } | { account: string }, origin: string, lifetime: number): Promise<string> {
156
+ const token = randomBytes(32).toString('base64url')
157
+ await records.create(INVITES, { id: digest(token), ...carries, expiresAt: new Date(Date.now() + lifetime).toISOString() })
158
+ return `${origin}/?${'role' in carries ? 'invite' : 'reset'}=${token}`
159
+ }
160
+
146
161
  async function mintInvite(role: string, origin: string, lifetime: number): Promise<string> {
147
162
  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}`
163
+ return mintLink({ role }, origin, lifetime)
164
+ }
165
+
166
+ async function endSessions(id: string): Promise<void> {
167
+ const sessions = await records.list(SESSIONS, { filter: { userId: id }, limit: 500 })
168
+ for (const session of sessions.rows) await records.remove(SESSIONS, session.id).catch(() => {})
151
169
  }
152
170
 
153
171
  return {
@@ -248,12 +266,31 @@ export function createAccounts(records: RecordStore, config: AccountsConfig) {
248
266
  requireManager(actor)
249
267
  const account = await target(id)
250
268
  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(() => {})
269
+ await endSessions(account.id)
253
270
  await records.remove(ACCOUNTS, account.id)
254
271
  changes.emit('change', account.id)
255
272
  },
256
273
 
274
+ /**
275
+ * A one-use link, good for 24 hours, that lets this member choose a new password. Nothing is
276
+ * removed, so the last-manager rule does not apply and the account keeps everything but its hash.
277
+ */
278
+ async reset(actor: Principal, id: string, origin: string): Promise<string> {
279
+ requireManager(actor)
280
+ return mintLink({ account: (await target(id)).id }, origin, day)
281
+ },
282
+
283
+ /** Spends a reset link: the new hash, and every session of that account gone. */
284
+ async setPassword(input: unknown): Promise<void> {
285
+ const { reset, password } = parse(inputs.setPassword, input)
286
+ const hashed = await hash(password)
287
+ const id = String((await claim(reset, 'reset')).account)
288
+ const account = await target(id)
289
+ await records.update(ACCOUNTS, account.id, { password: hashed })
290
+ await endSessions(account.id)
291
+ changes.emit('change', account.id)
292
+ },
293
+
257
294
  /**
258
295
  * A one-use invite for the first managing role, for the terminal that starts the server:
259
296
  * only while no account exists at all, or when the operator explicitly asks for recovery.
@@ -209,9 +209,12 @@ async function handleAuth({ accounts, config, cookie }: Server, principal: Princ
209
209
  return send(response, 200, { result: null }, session('', 0))
210
210
  }
211
211
  if (route === 'invites') return send(response, 200, { result: await accounts.invite(principal, input, originOf(config, request.headers.host)) })
212
- const member = route.match(/^members\/([^/]+)\/(role|groups|remove)$/)
212
+ // Spending a reset link is the one auth write a signed-out caller makes: the token is the credential.
213
+ if (route === 'password') { await accounts.setPassword(input); return send(response, 200, { result: null }) }
214
+ const member = route.match(/^members\/([^/]+)\/(role|groups|remove|reset)$/)
213
215
  if (!member) return send(response, 404, { error: 'Unknown API route' })
214
216
  const id = decodeURIComponent(member[1])
217
+ if (member[2] === 'reset') return send(response, 200, { result: await accounts.reset(principal, id, originOf(config, request.headers.host)) })
215
218
  if (member[2] === 'role') await accounts.setRole(principal, id, input)
216
219
  else if (member[2] === 'groups') await accounts.setGroups(principal, id, input)
217
220
  else await accounts.remove(principal, id)
@@ -70,7 +70,7 @@ export function App() {
70
70
  if ((next.user?.id ?? null) !== signedInAs.current) {
71
71
  // A used invite link must not reopen sign-up on the next load.
72
72
  const url = new URL(window.location.href)
73
- url.searchParams.delete('invite')
73
+ for (const param of ['invite', 'reset']) url.searchParams.delete(param)
74
74
  forgetBrowserSession()
75
75
  window.location.replace(url)
76
76
  }
@@ -115,7 +115,9 @@ export function App() {
115
115
  const accounts = me?.accounts
116
116
  const authConfig = accounts && { workspaceName: projectConfig.title, mode: 'password' as const, allowSignUp: accounts.allowSignUp, roles: accounts.roles }
117
117
  const manages = Boolean(me?.user?.roles.some((role) => accounts?.roles.some((one) => one.id === role && one.manages)))
118
- const invited = new URLSearchParams(window.location.search).has('invite')
118
+ // An invite or a reset link opens the Auth card even before anyone is signed in. Read once, so
119
+ // spending the link — which takes its token out of the URL — does not swap the card mid-flow.
120
+ const [invited] = useState(() => ['invite', 'reset'].some((param) => new URLSearchParams(window.location.search).has(param)))
119
121
  const shellAdapters = { identity: accounts ? identity : anonymousIdentity, navigation }
120
122
  const hasBrain = (projectConfig as { brain?: boolean }).brain === true
121
123
  // The bottom menu bar: the app's own screens first, then Brain, then Admin for managers.
package/src/client.ts CHANGED
@@ -126,6 +126,8 @@ export const identity: IdentityAdapter = {
126
126
  invite: (role) => auth<string>('invites', { role }),
127
127
  removeMember: async (userId) => { await auth(`members/${encodeURIComponent(userId)}/remove`); await reloadIdentity() },
128
128
  setRole: async (userId, role) => { await auth(`members/${encodeURIComponent(userId)}/role`, { role }); await reloadIdentity() },
129
+ resetPassword: (userId) => auth<string>(`members/${encodeURIComponent(userId)}/reset`),
130
+ setPassword: async (reset, password) => { await auth('password', { reset, password }); await reloadIdentity() },
129
131
  }
130
132
 
131
133
  /** Re-reads who is signed in and tells identity subscribers; for callers that saw a 401 or 403. */