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 +4 -0
- package/docs/app-backend.md +3 -2
- package/package.json +2 -2
- package/src/backend/accounts.ts +47 -10
- package/src/backend/http.ts +4 -1
- package/src/browser/app.tsx +4 -2
- package/src/client.ts +2 -0
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.
|
package/docs/app-backend.md
CHANGED
|
@@ -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
|
-
-
|
|
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.
|
|
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.
|
|
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",
|
package/src/backend/accounts.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
-
|
|
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.
|
package/src/backend/http.ts
CHANGED
|
@@ -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
|
-
|
|
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)
|
package/src/browser/app.tsx
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
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. */
|