Skip to content

Commit 365a7e1

Browse files
idoubiclaude
andcommitted
feat: stable app-user identity across api-key rotation + workspace/chat UX
Identity: - app_users (end-users provisioned via api_key) are now keyed on the api_key's OWNER account, not the api_key id. SwitchToAppUser passes ident.UserID (the owner, pre-switch) so the calling app can rotate/replace its api_key — a new key row under the same account — without orphaning the end-user's sessions / files / agents. Covers the X-Fastclaw-End-User header, the OpenAI `user` field, and POST /v1/users (all route through SwitchToAppUser). The inbound IM path was already owner-scoped. Collision-safe, idempotent, non-fatal migration re-keys existing api_key-minted app_users onto their owner. Workspace / chat UI: - Resizable 3-pane layout: the platform sidebar is now drag-resizable (rail = drag to resize / click to toggle, persisted, clamped); the workspace panel caps at 70% of its container so expanding the sidebar auto-shrinks it and the page never scrolls horizontally; opens at a comfortable width. - File viewer: Files tab is a tree + inline viewer split (no modal); tree default-expands one level; clicking a file shows highlighted SOURCE by default, full-bleed, no card / copy / download chrome; "open in new tab" by the close button; collapsible tree. - Non-binary files (.env, Dockerfile, .sql, extension-less, unknown) render as text instead of a download prompt; only genuine binaries offer download. - Reset the viewer's file + content on conversation switch (no stale preview). - Preview tab only shown for coding projects with a live dev server. - Assistant messages use the full lane width on narrow panels; composer aligns with the message rows (scrollbar gutter). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 303b8aa commit 365a7e1

7 files changed

Lines changed: 437 additions & 214 deletions

File tree

internal/auth/auth.go

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -252,20 +252,28 @@ func (r *Resolver) ResolveBearer(ctx context.Context, token string) (Identity, e
252252
}, nil
253253
}
254254

255-
// SwitchToAppUser rebinds ident to the app_user associated with
256-
// (ident.APIKeyID, externalID), minting that row the first time it's
257-
// seen. APIKeyID + APIKeyAgents are preserved — only UserID and Role
258-
// flip — so the apikey's agent ACL still gates access. Pass through
259-
// empty externalID untouched. Only valid for AuthMethod=="apikey";
260-
// session callers stay as-is.
255+
// SwitchToAppUser rebinds ident to the app_user for (owner account,
256+
// externalID), minting that row the first time it's seen. The app_user is
257+
// keyed on the api_key's OWNER account — NOT the api_key id — so the calling
258+
// app can rotate/replace its api_key without orphaning the end-user. APIKeyID
259+
// + APIKeyAgents are preserved (only UserID + Role flip) so the apikey's agent
260+
// ACL still gates access. Empty externalID passes through untouched. Only
261+
// valid for AuthMethod=="apikey"; session callers stay as-is.
261262
func (r *Resolver) SwitchToAppUser(ctx context.Context, ident Identity, externalID string) (Identity, error) {
262263
if externalID == "" {
263264
return ident, nil
264265
}
265266
if ident.AuthMethod != "apikey" || ident.APIKeyID == "" {
266267
return ident, errors.New("auth.SwitchToAppUser: api_key auth required")
267268
}
268-
acc, err := r.accounts.EnsureAppUser(ctx, ident.APIKeyID, externalID, "")
269+
// Already an app_user (request switched once) — re-keying off the
270+
// app_user's own id would mint a nested user. No-op instead.
271+
if ident.Role == users.RoleAppUser {
272+
return ident, nil
273+
}
274+
// ident.UserID is the api_key's owner account here (pre-switch); key the
275+
// app_user on it so rotating/replacing the api_key keeps the same user.
276+
acc, err := r.accounts.EnsureAppUser(ctx, ident.UserID, externalID, "")
269277
if err != nil {
270278
return ident, err
271279
}

internal/store/database.go

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"encoding/json"
1010
"errors"
1111
"fmt"
12+
"log/slog"
1213
"strings"
1314
"time"
1415

@@ -793,6 +794,32 @@ func (d *DBStore) migrateUsersAppUserCols(ctx context.Context) error {
793794
WHERE apikey_id <> '' AND external_id <> ''`); err != nil {
794795
return fmt.Errorf("create idx_users_apikey_external: %w", err)
795796
}
797+
798+
// One-time, collision-safe re-key: app_users minted under the OLD scheme
799+
// stored the api_key id as their mint scope, which orphans the end-user
800+
// when the calling app rotates/replaces that key. Re-key them onto the
801+
// api_key's OWNER account so identity survives key rotation. Only rows
802+
// whose apikey_id still resolves to a real api_key are remapped; rows that
803+
// would collide with an already-owner-keyed sibling (same owner +
804+
// external_id) are skipped. Idempotent: once apikey_id holds a "u_…" owner
805+
// it no longer matches any apikeys.id ("k_…"), so reruns touch nothing.
806+
// Non-fatal: a rare unrecoverable collision (two legacy keys, same owner,
807+
// same external_id) is logged and left for manual reconciliation rather
808+
// than blocking startup.
809+
if _, err := d.db.ExecContext(ctx, `
810+
UPDATE users SET apikey_id = (SELECT a.user_id FROM apikeys a WHERE a.id = users.apikey_id)
811+
WHERE role = 'app_user'
812+
AND apikey_id <> ''
813+
AND apikey_id IN (SELECT id FROM apikeys)
814+
AND NOT EXISTS (
815+
SELECT 1 FROM users u2
816+
WHERE u2.id <> users.id
817+
AND u2.role = 'app_user'
818+
AND u2.external_id = users.external_id
819+
AND u2.apikey_id = (SELECT a.user_id FROM apikeys a WHERE a.id = users.apikey_id)
820+
)`); err != nil {
821+
slog.Warn("migrate: backfill app_user owner scope failed (non-fatal)", "error", err)
822+
}
796823
return nil
797824
}
798825

internal/users/account.go

Lines changed: 20 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -329,22 +329,27 @@ func (a *Accounts) SetPassword(ctx context.Context, id, newPassword string) erro
329329
return a.store.UpdateUser(ctx, rec)
330330
}
331331

332-
// EnsureAppUser returns the fastclaw user representing (apikeyID, externalID),
332+
// EnsureAppUser returns the fastclaw user representing (scopeNS, externalID),
333333
// creating one with role=app_user the first time it's seen. Idempotent:
334-
// later calls with the same pair return the existing row. The caller is
335-
// expected to be the api_key owner — Mint does not authenticate, that's
336-
// the auth middleware's job. Username/email are synthesized from the
337-
// pair and namespaced ("ext:<apikeyID>:<externalID>") so they don't
338-
// collide with real human signups but still satisfy the UNIQUE
339-
// constraints on those columns.
340-
func (a *Accounts) EnsureAppUser(ctx context.Context, apikeyID, externalID, displayName string) (*Account, error) {
341-
apikeyID = strings.TrimSpace(apikeyID)
334+
// later calls with the same pair return the existing row.
335+
//
336+
// scopeNS is the STABLE namespace the external id is unique within — the
337+
// api_key OWNER account for the REST/OpenAI path (so the calling app can
338+
// rotate its api_key freely), or a channel namespace for inbound IM. It is
339+
// deliberately NOT the api_key id. Stored in the users.apikey_id column,
340+
// which is really a generic mint-scope slot.
341+
//
342+
// Username/email are synthesized from the pair and namespaced
343+
// ("ext:<scopeNS>:<externalID>") so they don't collide with real human
344+
// signups but still satisfy the UNIQUE constraints on those columns.
345+
func (a *Accounts) EnsureAppUser(ctx context.Context, scopeNS, externalID, displayName string) (*Account, error) {
346+
scopeNS = strings.TrimSpace(scopeNS)
342347
externalID = strings.TrimSpace(externalID)
343-
if apikeyID == "" || externalID == "" {
344-
return nil, errors.New("users.EnsureAppUser: apikeyID and externalID are required")
348+
if scopeNS == "" || externalID == "" {
349+
return nil, errors.New("users.EnsureAppUser: scopeNS and externalID are required")
345350
}
346351
// Fast path — already provisioned.
347-
if rec, err := a.store.GetUserByExternal(ctx, apikeyID, externalID); err == nil {
352+
if rec, err := a.store.GetUserByExternal(ctx, scopeNS, externalID); err == nil {
348353
return toAccount(rec), nil
349354
} else if !errors.Is(err, store.ErrNotFound) {
350355
return nil, err
@@ -356,7 +361,7 @@ func (a *Accounts) EnsureAppUser(ctx context.Context, apikeyID, externalID, disp
356361
// Synthesize unique username/email tokens. The downstream app
357362
// is the source of truth for the human-readable identity; we
358363
// only need *something* unique to satisfy the schema.
359-
syn := apikeyID + ":" + externalID
364+
syn := scopeNS + ":" + externalID
360365
rec := &store.UserRecord{
361366
ID: id,
362367
Username: "ext:" + syn,
@@ -365,7 +370,7 @@ func (a *Accounts) EnsureAppUser(ctx context.Context, apikeyID, externalID, disp
365370
DisplayName: displayName,
366371
Role: RoleAppUser,
367372
Status: StatusActive,
368-
APIKeyID: apikeyID,
373+
APIKeyID: scopeNS,
369374
ExternalID: externalID,
370375
AgentQuota: -1,
371376
}
@@ -374,7 +379,7 @@ func (a *Accounts) EnsureAppUser(ctx context.Context, apikeyID, externalID, disp
374379
// between our GetUserByExternal and CreateUser. Re-read
375380
// and return that row instead of bubbling the unique
376381
// violation up to the caller.
377-
if again, qerr := a.store.GetUserByExternal(ctx, apikeyID, externalID); qerr == nil {
382+
if again, qerr := a.store.GetUserByExternal(ctx, scopeNS, externalID); qerr == nil {
378383
return toAccount(again), nil
379384
}
380385
return nil, err

web/src/app/globals.css

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -290,6 +290,17 @@ html.theme-transition *::after {
290290
.chat-md [data-streamdown="code-block"] {
291291
@apply !border-0 !bg-transparent !p-0 !rounded-none !min-w-0 !max-w-full !overflow-x-auto relative;
292292
}
293+
294+
/* File-viewer (bareCode) mode: no copy pill, no card border/padding — the
295+
source fills the pane edge-to-edge (line numbers are the only gutter). */
296+
.chat-md-bare [data-streamdown="code-block-actions"],
297+
.chat-md-bare [data-streamdown="code-block-header"] { @apply !hidden; }
298+
.chat-md-bare [data-streamdown="code-block"],
299+
.chat-md-bare [data-streamdown="code-block"] > div,
300+
.chat-md-bare [data-streamdown="code-block"] pre,
301+
.chat-md-bare [data-streamdown="code-block"] code {
302+
@apply !border-0 !bg-transparent !rounded-none !p-0 !m-0;
303+
}
293304
.chat-md [data-streamdown="code-block"] pre { @apply !max-w-full !min-w-0 !overflow-x-auto; }
294305
.chat-md [data-streamdown="code-block"] > div { @apply !min-w-0 !max-w-full; }
295306
.chat-md [data-streamdown="code-block-header"] { @apply !hidden; }

web/src/components/chat-markdown.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,10 +74,14 @@ export function ChatMarkdown({
7474
text,
7575
agentId,
7676
sessionId,
77+
bareCode = false,
7778
}: {
7879
text: string;
7980
agentId?: string;
8081
sessionId?: string;
82+
// File-viewer mode: hide the floating copy pill on code blocks (the .chat-md
83+
// strip already removes the card) so a source file reads as plain code.
84+
bareCode?: boolean;
8185
}) {
8286
// Build the URL transform once per agent/session. A stable identity keeps
8387
// Streamdown (a memo component) from re-rendering on every streamed keystroke,
@@ -119,7 +123,7 @@ export function ChatMarkdown({
119123
}
120124

121125
return (
122-
<div className={PROSE_CLASS} onClick={onMermaidClick} onWheelCapture={onWheelCapture}>
126+
<div className={bareCode ? PROSE_CLASS + " chat-md-bare" : PROSE_CLASS} onClick={onMermaidClick} onWheelCapture={onWheelCapture}>
123127
<Streamdown
124128
parseIncompleteMarkdown
125129
plugins={streamdownPlugins}

0 commit comments

Comments
 (0)