Personal User Tokens — Implementation Plan
Goal: let any user (including admin) issue a long-lived personal token and call API/MCP on their own behalf. MCP gains personal-token support automatically — tokens work everywhere usertoken cookie works.
Driving use case: test federated_search against the telegram demo KB via personal token.
Design
Generic, not MCP-specific
New table user_tokens — universal complement to cookie auth. One token = one user. Any endpoint that reads request.UserToken() automatically supports personal tokens.
Integration point
internal/appreq/request.go:97 Request.UserToken() — the single path to "who is this". All use cases go through CurrentUserToken(ctx). We extend this layer; the MCP endpoint gets no special logic (only the Bearer dispatch fix in Task 8).
Auth resolution order (Request.UserToken())
Resolution order, checked sequentially:
- Cookie
trip2g_token— current path, JWT. Authorization: Bearer <value>— if value starts witht2g_-> personal token resolve; otherwise ignored (federation JWT handled separately in MCP endpoint).?token=<value>— samet2g_prefix check.
Cookie wins. If a user is logged in via cookie, Bearer/query-param are ignored. This prevents an attacker from overriding a browser session.
Invalid personal token -> hard error (NOT anonymous fallback). This is an intentional asymmetry with cookie behavior (invalid cookie -> anonymous). Justification: explicit token usage implies intent; silent anonymous fallback would confuse API consumers who expect their token to authenticate them.
t2g_ prefix -> disambiguation
- Personal token:
t2g_+ 64 random alnum (stored as hash in DB). - Federation JWT: format
<b64>.<b64>.<b64>(never collides witht2g_*). - Cookie JWT: also JWT, but transmitted via cookie — never confused with personal token in Bearer.
Schema
create table user_tokens (
id text primary key, -- uuid
user_id integer not null references users(id) on delete cascade,
name text not null default '', -- label, e.g. "Claude Desktop"
token_hash text not null unique, -- sha256(plaintext_with_t2g_prefix)
token_prefix text not null, -- 't2g_aBcD' (8 chars)
scope text not null default 'all', -- reserved for phase 2 (e.g. 'mcp', 'read-only')
created_at datetime not null default current_timestamp,
expires_at datetime, -- nullable (null = no expiry)
last_used_at datetime,
revoked_at datetime
);
create index idx_user_tokens_user_id on user_tokens(user_id);
create index idx_user_tokens_token_hash on user_tokens(token_hash);
Analytics and phase 2 fields:
last_used_at— updated on each successful resolve (best-effort, fire-and-forget goroutine, throttled to 1 write per 5 minutes per token).expires_at— set at creation. UI default = 90 days. GraphQLexpiresInDaysis nullable (null = never expires).scope— reserved for phase 2 (per-token subgraph scope, read-only mode). Always'all'in phase 1.
What we skip in phase 1
- Per-token scope (field exists, unused).
- Rate limit per token.
- Audit log per request (only
last_used_at). If needed — separateuser_token_logstable. - TTL-based cleanup of expired tokens from DB — filter on read by
expires_at < now()orrevoked_at is not null.
Tasks
Task 1 — Migration user_tokens
db/migrations/20260430100000_create_user_tokens_table.sql (see schema above).
dbmate up && dbmate dump. Commit migration + db/schema.sql.
Acceptance: migration applies cleanly; db/schema.sql contains user_tokens table and both indexes.
Task 2 — sqlc queries
queries.write.sql:
InsertUserToken :oneRevokeUserToken :one(where id=? and user_id=? and revoked_at is null returning *)UpdateUserTokenLastUsedAt :exec
queries.read.sql:
UserTokenByHash :one— returns row whererevoked_at is null and (expires_at is null or expires_at > datetime('now')).ListUserTokensByUserID :many(order by created_at desc).CountActiveUserTokensByUserID :one(where revoked_at is null and (expires_at is null or expires_at > datetime('now'))).
make sqlc. go build ./internal/db/....
Acceptance: make sqlc succeeds; generated Go code compiles; all 6 queries present in generated files.
Task 3 — internal/personaltoken/ package
const Prefix = "t2g_"
func Generate() string // Prefix + 64 random alnum
func Hash(plaintext string) string // sha256 hex
func DisplayPrefix(plaintext string) string // first 8 chars
func IsPersonal(s string) bool // strings.HasPrefix(s, Prefix)
TDD: length, uniqueness, hash is idempotent, IsPersonal true/false, different plaintexts produce different hashes.
Acceptance: go test ./internal/personaltoken/... passes; all functions exported and tested.
Task 4 — Resolver: plaintext -> usertoken.Data (with cache + throttle)
internal/personaltoken/resolver.go:
type Env interface {
UserTokenByHash(ctx context.Context, hash string) (db.UserToken, error)
AdminByUserID(ctx context.Context, userID int64) (db.Admin, error) // reuse existing query
UpdateUserTokenLastUsedAt(ctx context.Context, id string) error
}
type Resolver struct {
env Env
cache sync.Map // key: token hash (string), value: cacheEntry
used sync.Map // key: token ID (string), value: time.Time (last DB write)
}
type cacheEntry struct {
data *usertoken.Data
fetchedAt time.Time
}
func NewResolver(env Env) *Resolver
func (r *Resolver) Resolve(ctx context.Context, plaintext string) (*usertoken.Data, error)
In-process cache (30s TTL):
- On resolve: compute hash, check
cachesync.Map. If entry exists and age < 30s, return cached. - On miss: DB lookup (
UserTokenByHash+AdminByUserID), populate cache entry. - On revoke: no explicit invalidation (30s staleness window is acceptable).
- Concurrency-safe via
sync.Map.
last_used_at throttle (5-minute window):
- Separate
usedsync.Map keyed by token ID, value =time.Timeof last DB write. - On resolve success: if last write was < 5 minutes ago, skip. Otherwise fire-and-forget goroutine:
UpdateUserTokenLastUsedAt+ update map entry. - Bounded by active token count (max 10/user); no eviction needed.
- Cold start = first use always writes (acceptable).
Role lookup: reuse existing AdminByUserID query (internal/db/queries.read.sql.go:90). Same pattern as SetupUserToken in cmd/server/main.go:1376. Do NOT create a new UserRoleByID query. If AdminByUserID returns a row -> role is "admin", otherwise "user".
Returns *usertoken.Data{ID: int(token.UserID), Role: role}. Errors (not found, expired, revoked) -> ErrInvalidToken.
Resolver lifecycle:
- Single long-lived instance (holds caches). Constructed at server init in
cmd/server/main.go. - Wired to
appreq.Requestat the same site that already setsreq.TokenManager. Find that site explicitly and reference it in implementation. - NOT created in
Acquire()(which takes no params). AfterAcquire(), in the request entrypoint that already configuresRequestfields.
TDD: valid token resolves, expired returns error, revoked returns error, not-found returns error, admin role detected, non-admin role detected, cache hit on second call within 30s, cache miss after 30s, last_used_at throttle skips write within 5 minutes.
Acceptance: go test ./internal/personaltoken/... passes; resolver correctly caches and throttles; AdminByUserID reused (no new role query).
Task 5 — Extend appreq.Request.UserToken()
File: internal/appreq/request.go
Add optional personal-token resolver to Request:
type PersonalTokenResolver interface {
Resolve(ctx context.Context, plaintext string) (*usertoken.Data, error)
}
type Request struct {
// ...existing fields...
PersonalTokenResolver PersonalTokenResolver
}
In UserToken() after cookie extraction:
- If cookie yields non-nil -> return (current behavior).
- Else check
Authorization: Bearer <value>: if value starts witht2g_->PersonalTokenResolver.Resolve(ctx, value). - Else check
?token=<value>: samet2g_prefix check. - Else ->
nil(anonymous).
Request.Reset() must nil the PersonalTokenResolver field (internal/appreq/request.go:44-55) to prevent sync.Pool contamination. The resolver instance itself is long-lived and shared; the field on Request just references it, but must be cleared on pool return.
Wiring: in cmd/server/main.go, after Acquire(), set req.PersonalTokenResolver = resolverInstance at the same site that sets req.TokenManager.
Required test cases:
- Cookie valid + Bearer
t2g_valid -> cookie user wins. - Cookie absent + Bearer
t2g_valid -> personal user returned. - Cookie absent + Bearer
t2g_invalid/revoked/expired ->ErrInvalidToken(hard error, NOT anonymous). - Cookie absent + Bearer non-
t2g_(federation JWT format) -> nil fromUserToken()(handled downstream by MCP endpoint). - Cookie absent +
?token=t2g_valid -> personal user returned. - Cookie absent + no auth headers -> nil (anonymous).
PersonalTokenResolvernot wired (nil) + Bearert2g_present -> return clear error (not panic). Specify: returnfmt.Errorf("personal token resolver not configured")or similar.
Acceptance: all 7 test cases pass; Reset() nils the resolver field; go test ./internal/appreq/... passes.
Task 6 — Use cases: create/revoke
internal/case/createusertoken/:
type Env interface {
CurrentUserToken(ctx) (*usertoken.Data, error)
CountActiveUserTokensByUserID(ctx, userID int64) (int64, error)
GenerateUniqID() string
InsertUserToken(ctx, db.InsertUserTokenParams) (db.UserToken, error)
}
type Input struct {
Name string
ExpiresIn *time.Duration // nil = no expiry
}
type SuccessPayload struct{ PlaintextToken string; Token db.UserToken }
type ErrorPayload struct{ Message string }
Active token limit per user: 10. TDD: success, limit exceeded, expires_at computed correctly from duration.
internal/case/revokeusertoken/ — owner check via where id=? and user_id=?.
Acceptance: go test ./internal/case/createusertoken/... and revokeusertoken pass; limit enforced; expiry computed.
Task 7 — GraphQL
schema.graphqls:
type UserToken {
id: ID!
name: String!
tokenPrefix: String!
scope: String!
createdAt: Time!
expiresAt: Time
lastUsedAt: Time
revokedAt: Time
}
type CreateUserTokenPayload { plaintextToken: String! token: UserToken! }
type RevokeUserTokenPayload { token: UserToken! }
input CreateUserTokenInput { name: String! expiresInDays: Int } # null = no expiry
input RevokeUserTokenInput { id: ID! }
extend type User { tokens: [UserToken!]! }
extend type Mutation {
createUserToken(input: CreateUserTokenInput!): CreateUserTokenPayload!
revokeUserToken(input: RevokeUserTokenInput!): RevokeUserTokenPayload!
}
make gqlgen. Resolvers wrap the use cases.
User.tokens resolver: calls ListUserTokensByUserID(currentUser.ID).
npm run graphqlgen.
Acceptance: make gqlgen and npm run graphqlgen succeed; resolvers compile; go build ./... passes.
Task 8 — MCP endpoint dispatch + integration test
8a — Bearer dispatch in MCP endpoint
File: internal/case/mcp/endpoint.go:32-45
BEFORE calling verifyInbound, branch on the result of appreq.Request.UserToken() (already resolved by middleware from Task 5):
if user present in ctx (via appreq):
// personal token already authenticated the user, skip verifyInbound
proceed with user context
else if Bearer header present and value is NOT t2g_*:
verifyInbound(bearer) // federation JWT path
else:
anonymous
Key design decision: branch on UserToken() result (user in ctx), NOT on the prefix string in two places. This keeps token-type routing centralized in appreq.
Test: Bearer with t2g_ prefix does NOT trigger verifyInbound; the user from the personal token reaches accessibleKBNotes.
8b — canReadMCPNote / accessibleKBNotes audit
No code change required. Audit confirms the chain works:
env.CanReadNote(ctx, ...) -> canreadnote.Resolve -> CurrentUserToken(ctx) -> appreq.FromCtx(ctx).UserToken().
Once Task 5 is done, the personal-token user is in ctx, and the existing ACL logic applies correctly. Admin user -> sees all. Non-admin -> filtered by subgraph subscription.
Integration test (required): MCP endpoint with personal token Bearer -> accessibleKBNotes returns notes visible to that user:
- Admin token -> all KB notes visible.
- Non-admin token with subgraph subscription -> only notes in subscribed subgraphs.
- Non-admin token without subscription to a specific subgraph -> those notes filtered out (not error).
Exercises the full ctx chain end-to-end, not mocked.
Acceptance: dispatch test passes (personal token skips verifyInbound); integration test passes for admin and non-admin paths; go test ./internal/case/mcp/... passes.
Task 9 — UI: Personal Tokens tab
assets/ui/user/space/space.view.tree: new tab tokens <= Tokens $trip2g_user_space_tokens.
assets/ui/user/space/tokens/:
tokens.view.tree—$mol_pagewith token list: name, prefix, scope, created/last_used/expires, Revoke button. Top panel "Add token": input name, select expiry (default = 90 days; options: 30d / 90d / 1y / never), Generate button.tokens.view.ts— three GraphQL operations (list, create, revoke). After create: show modal with plaintext + ready URL${origin}/_system/mcp?token=${plaintext}+ cURL example, with$mol_button_copy. Plaintext kept only in@$mol_memfield (not in DB).*.locale=ru.json.
npm run build, smoke in browser.
Acceptance: tab renders; token creation shows plaintext modal; revoke works; default expiry selection is 90 days; npm run build succeeds.
Task 10a — Unit & integration tests (Go)
Co-located with implementation tasks (3, 4, 5, 6, 8). Listed here as a checklist so coverage is not left to chance.
internal/personaltoken/ (Task 3-4):
Generatelength, prefixt2g_, uniqueness across N calls.Hashis deterministic, idempotent; different plaintexts -> different hashes.IsPersonaltrue/false matrix (t2g_…, empty,Bearer t2g_…, federation JWT,T2g_case-sensitivity).Resolver.Resolve: valid -> returns*usertoken.Datawith right ID + role; expired ->ErrInvalidToken; revoked ->ErrInvalidToken; not found ->ErrInvalidToken; admin role detected viaAdminByUserID; non-admin role.- Cache: second call within 30s does NOT hit
UserTokenByHash; after 30s does. Verified via mock-call counts. - Throttle: second call within 5min does NOT call
UpdateUserTokenLastUsedAt; after 5min does. Mock-based.
internal/case/createusertoken/, internal/case/revokeusertoken/ (Task 6):
createusertoken: success returns plaintext + token row; limit exceeded (count >= 10) returnsErrorPayload;ExpiresIn = nil->expires_atnull;ExpiresIn = 90d->expires_atset.revokeusertoken: owner can revoke; not-owner attempt ->ErrNotFound; double revoke ->ErrNotFound(already revoked).
internal/appreq/request_test.go (Task 5) — table-driven, all 7 cases from Task 5 acceptance:
- Cookie valid + Bearer
t2g_*valid -> cookie user (cookie wins; assertResolvenot called via mock counter). - Cookie absent + Bearer
t2g_*valid -> personal user. - Cookie absent + Bearer
t2g_*invalid ->ErrInvalidToken(NOT nil, NOT anonymous). - Cookie absent + Bearer non-
t2g_-> nil (federation JWT path is not appreq's concern). - Cookie absent +
?token=t2g_*valid -> personal user. - Cookie absent + no auth -> nil.
PersonalTokenResolverfield == nil + Bearert2g_*present -> clear error (no panic). Document chosen behavior.Reset()nilsPersonalTokenResolver(verified via reflect or assertion afterRelease).
internal/case/mcp/endpoint_test.go + resolve_test.go (Task 8):
- Bearer
t2g_*does NOT triggerverifyInbound(mock the federation env so averifyInboundcall would fail; assert no failure surfaces — meaning it wasn't called). - Bearer
t2g_*->accessibleKBNotesreturns notes filtered for that user (admin sees all KB; non-admin sees only KB-notes in their subgraph). - Bearer non-
t2g_JWT -> federation path runs as before (regression). - No Bearer + cookie set -> existing behavior (regression).
Task 10b — Playwright e2e (e2e/personal-tokens.spec.js)
Use existing two-instance harness from e2e/federation.spec.js (hub :20081, peer :20091). Reuse e2e/helpers/auth.js (graphqlSignIn) and the mcpCall pattern.
New helper additions:
e2e/helpers/auth.js: addcreatePersonalToken(request, baseURL, cookie, { name, expiresInDays })that runscreateUserTokenmutation and returnsplaintextToken. AddrevokePersonalToken(request, baseURL, cookie, id).mcpCall(request, url, method, params, { bearer, queryToken })— extend to accept Bearer header and?token=injection.
Test groups (test.describe.serial):
Group 1 — Auth resolution (single instance, hub):
createUserTokenunder admin cookie -> plaintext starts witht2g_, length 68.- MCP
tools/call searchviaAuthorization: Bearer t2g_…-> 200, returns hub-local notes. - Same call via
?token=t2g_…query param -> identical result. - Cookie of user A + Bearer of user B's token ->
viewer.user.idresolves to A (cookie wins). Verified by calling a GraphQL query that exposes current user identity. - Bearer
t2g_invalid-> JSON-RPC error response (not anonymous results). - Token created with
expiresInDays: 0then forced into the past via direct DB UPDATE (or freezingtime.Nowif injectable) -> Bearer call returns error. revokeUserToken-> next call with same plaintext returns error within ~30s (cache TTL window — test waits or asserts >=30s gap; if flaky, hit theclearCachetest-only endpoint or restart resolver).
Group 2 — ACL for non-admin (single instance):
8. Setup: admin creates non-admin user, subgraph A (subscribed), subgraph B (not), 1 note in each.
9. Personal token of non-admin -> search returns note from A only.
10. Personal token of admin -> search returns notes from A and B.
Group 3 — Federation via personal token (hub + peer):
11. Setup (extends federation.spec.js bootstrap): KB-note on hub with mcp_federation_kb_url: http://localhost:20091/_system/mcp, mcp_federation_kb_id: peer-kb, in subgraph A. Peer has at least one searchable note.
12. Admin's personal token -> federated_search query="…" kb_id="peer-kb" -> returns peer notes (proves chain: hub MCP → user-resolved → KB-note visible → outbound federation → peer).
13. Non-admin (subscribed to A)'s personal token -> same federated_search -> works (KB-note visible).
14. Non-admin NOT subscribed to A -> KB-note hidden -> federated_search kb_id="peer-kb" returns federation_not_configured payload, not error.
15. federated_search without kb_id (fan-out) under non-admin token -> only peers from KB-notes the user can see are queried. Verify by adding a second KB-note in subgraph B and confirming non-admin (subscribed only to A) sees results from peer-A only.
Group 4 — Smoke / regressions:
16. Two rapid sequential calls with same token -> both succeed (cache no-regression).
17. Existing federation tests (federation.spec.js) still pass — i.e. plain federation Bearer JWT path unaffected. (Run as part of full e2e suite, not duplicated here.)
Test data setup details:
beforeAll: graphqlSignIn admin on hub + peer, bootstrap federation secrets (reusefederation.spec.jshelper), create non-admin user, create subgraph A/B, subscribe non-admin to A, create test notes, create KB-notes pointing at peer.afterAll: revoke any leftover personal tokens; cleanup (or rely on testdata reset between runs).- For test-time clock control on expiry: prefer direct DB UPDATE
expires_at = datetime('1970-01-01')rather than introducing a clock interface in phase 1.
Acceptance:
npm run test:e2e -- e2e/personal-tokens.spec.jspasses locally and in CI.- All 17 scenarios produce expected results.
federation.spec.jscontinues to pass (no regression).- Helper additions to
auth.jscovered by usage in this spec.
Task 11 — Documentation
docs/en/user/mcp.md+docs/ru/user/mcp.md: section "Personal access token" — how to create, formats (?token=,Bearer), ACL behavior.docs/dev/mcp_federation.md: note that federation now works per-user via personal tokens.- If
docs/dev/auth.mdexists — describe the new auth channel.
Acceptance: both language versions exist; content covers creation, usage formats, and ACL semantics.
Task Order
1 -> 2 -> 3 -> 4 (parallel with 6) -> 5 (depends on 4) -> 7 -> 8 -> 9 -> 10 -> 11.
Task 8 can start as soon as Task 5 is done. Without it, personal-token users won't see KB notes in private subgraphs.
ADR: Personal Token Authentication
Decision: extend appreq.Request.UserToken() with a PersonalTokenResolver that handles t2g_-prefixed Bearer/query tokens, with an in-process cache and last_used_at throttle.
Drivers:
- MCP consumers need stable, long-lived auth (cookies are session-bound and browser-only).
- Personal tokens must work transparently with all existing ACL checks (no per-endpoint changes).
- DB load from token resolution must stay minimal (tokens are checked on every request).
Alternatives considered:
- A) Separate MCP-specific auth middleware. Rejected: duplicates ACL logic, doesn't generalize to other API consumers.
- B) No cache, direct DB lookup every request. Rejected: personal tokens hit on every MCP tool call (potentially many per session); even with SQLite read pool, this adds unnecessary load. 30s cache with
sync.Mapis simple and bounded. - C) Redis/external cache. Rejected: trip2g is a single-process SQLite app.
sync.Mapis simpler and has zero operational overhead. - D) Eager cache invalidation on revoke. Rejected: adds complexity for marginal benefit. 30s staleness is acceptable; revocation is rare.
Why chosen: Option A (extend appreq) with in-process cache gives transparent auth for all endpoints, minimal code surface, and acceptable performance. The cache-vs-live-lookup tradeoff is the principal tension: we accept 30s staleness on token data and 5-minute staleness on last_used_at in exchange for dramatically fewer DB round-trips.
Consequences:
- Revoked tokens remain valid for up to 30 seconds after revocation.
last_used_atis approximate (5-minute granularity). Acceptable for analytics; not suitable for real-time audit.sync.Mapmemory is bounded by active token count (max 10/user, typically single-digit total).
Follow-ups (phase 2):
- Per-token scope (
mcp,read-only, per-subgraph). - Rate limiting per token.
- Request audit log (
user_token_logstable). - Configurable cache TTL if 30s proves too aggressive.