* feat: expand audit event coverage across all state-changing operations
Add audit events for share lifecycle, object operations, team management,
admin/system mutations, storage, quotas, invite codes, site invitations,
users, licensing, and branding.
New events recorded:
- Share: share_create, share_revoke
- Object: upload_confirm, upload_cancel, object_copy, object_purge,
batch_trash, batch_purge, trash_empty
- Team/org: team_invite_link_create, team_member_join, team_logo_update,
team_logo_delete
- System: system_option_set, system_option_delete
- Storage: storage_create, storage_update, storage_delete
- Quota: quota_update
- Invite codes: invite_code_generate, invite_code_delete
- Site invitations: site_invitation_create, site_invitation_revoke
- Users: user_disable, user_enable, user_delete
- Licensing: license_pair, license_disconnect
- Branding: branding_update, branding_reset
Auth sign-up/sign-in events: not logged. The Better Auth hook lifecycle
does not provide reliable org context at sign-up (personal org is created
after the user.create.after hook), and sign-in cannot be distinguished
from normal session refresh without brittle internal request parsing.
No schema migration required; existing activityEvents table fields
support all new event types.
Add i18n translations (en + zh) for all new action names and target types.
Add 26 integration tests verifying every new event category and asserting
no secrets are stored in metadata.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Agent-Profile: https://agent-kanban.dev/agents/f68cfbce6456edb5
* feat: add missing audit event coverage for share_download, org lifecycle, license_refresh
Fill gaps identified in code review:
1. share_download: record after successful file download via public share
endpoint (publicShares GET /:token/objects/:ref). Authenticated viewer
is used as actor; share creator is proxy for anonymous downloads with
anonymous:true in metadata. Presigned URL never stored.
2. team lifecycle via Better Auth organizationHooks (server/auth.ts):
- team_member_remove (afterRemoveMember): member.userId used as actor
since BA hook does not expose the initiator
- team_member_role_update (afterUpdateMemberRole): same limitation,
member.userId used; previousRole/newRole recorded in metadata
- team_settings_update (afterUpdateOrganization): actor user available
- team_delete (afterDeleteOrganization): actor user available
- team_member_join via email invite (afterAcceptInvitation): joins
existing link-based join coverage in teams.ts
3. license_refresh: record after POST /api/licensing/refresh succeeds
(licensing-admin.ts)
Unhookable events (documented with code references):
- invite_code_redeem: called at server/auth.ts:269 in user.create.after;
personal org (required for activityEvents.orgId NOT NULL) is created in
session.create.before which runs after this hook. Better Auth provides
no shared state between these hooks.
- site_invitation_accept: called at server/auth.ts:275 in user.create.after;
same orgId unavailability issue.
Add 7 new integration tests for: share_download (×2, authed + anonymous),
team_settings_update, team_member_remove, team_member_role_update,
team_delete, license_refresh.
Add i18n translations (en + zh) for new actions:
share_download, team_member_remove, team_member_role_update,
team_settings_update, team_delete, license_refresh.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Agent-Profile: https://agent-kanban.dev/agents/f68cfbce6456edb5
---------
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Add COMMUNITY_TEAM_LIMIT=3 to shared/constants so both server and frontend share one source of truth
- Add server/services/team-count-guard.ts: countUserOrgs + checkTeamLimit (consults licensing state)
- Hook into better-auth organization.beforeCreateOrganization to throw 402 when limit is reached
- Update /teams UI: isAtLimit guard (with loading protection) shows ProBadge on "New Team" button and opens UpgradeHint dialog instead of create dialog when user is at limit
Agent-Profile: https://agent-kanban.dev/agents/a6bb038c4226a87f
Co-authored-by: Bob <aibob@mails.agent-kanban.dev>
- Add server/services/signup-mode-guard.ts: getEffectiveSignupMode()
applies Pro check when stored mode is 'open'; non-Pro falls back to
invite-only so downgraded instances stay secure
- Update server/auth.ts: replace internal getSignupMode() with
getEffectiveSignupMode() from new service
- Update server/routes/system.ts: PUT auth_signup_mode=open returns 402
feature_not_available when open_registration feature is absent
- Update RegistrationModeSection: 'open' radio disabled with ProBadge
for non-Pro; clicking it opens UpgradeHint dialog instead of saving
- Add integration tests: 6-combination matrix (3 modes × 2 plans) plus
admin API guard tests
- Update auth.integration.test.ts: split open-mode tests for Pro/non-Pro
Agent-Profile: https://agent-kanban.dev/agents/a6bb038c4226a87f
Co-authored-by: Bob <aibob@mails.agent-kanban.dev>
- Allow empty Referer in allowlist check — matches industry standard
(Cloudflare, AWS, etc.): anti-hotlinking targets other websites, not
direct access from tools/address bar
- Support explicit 'path' field in JSON base64 uploads (uPic)
- Set global API key rate limit: 60 req / 60s window
- Remove temporary debug logging
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Bug 1: server/services/s3.ts — putObject passed Web ReadableStream to
AWS SDK which only accepts Node Readable or Uint8Array. Convert to
Uint8Array via Response.arrayBuffer() for cross-runtime compatibility.
Bug 2: server/routes/ihost.ts + shared/schemas — presign endpoint
returned 400 (zod validation) instead of 413 for oversized files.
Moved size check from schema .max() to handler with proper 413 status.
Bug 3: src/lib/api.ts — createIhostApiKey sent `permissions` in the
request body, but better-auth's apiKey plugin rejects client-set
permissions (SERVER_ONLY_PROPERTY). Removed it; server defaultPermissions
handles it automatically.
Also: session.create.before now checks org existence by slug before
creating, preventing UNIQUE constraint failures when membership was
revoked but org still exists.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
better-auth defers `user.create.after` hooks until after the transaction
commits, but the session cookie cache is written inside the transaction.
This caused `activeOrganizationId` to be null on first load after sign-up,
breaking features that depend on an active organization (e.g. Image Host).
Solution: create the personal org in `session.create.before` (which runs
inside the transaction, after user INSERT) and set `activeOrganizationId`
on the session before it's cached. Also keep idempotent org creation in
`user.create.after` so orgs are created even without auto sign-in (e.g.
when email verification is required).
Additionally fix an ambiguous Playwright locator in image-host.spec.ts
that matched multiple elements.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Users signing up via social login (OAuth/OIDC) don't provide a username.
Generate one automatically from the email prefix, with random suffix
fallback for conflicts or short prefixes.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat: team invitation via email and invite link
- Add team invite dialog with email invite and shareable link tabs
- Email invite uses better-auth organizationClient.inviteMember() with configured email service
- Invite link generates a time-limited token stored in new team_invite_links table
- Accept invite page at /teams/invite?token=xxx (auto-join if logged in, redirect to sign-in if not)
- Pending invitations section shows all pending email invites; owners can cancel them
- Add editor/viewer custom roles to better-auth organization plugin
- Add sendInvitationEmail hook to send HTML invite email via configured email service
- Redirect-after-login support: _authenticated layout passes current URL to sign-in
- Add migration 0007_team_invite_links for new table
- Only team owners see the Invite Member button and pending invitations
Agent-Profile: https://agent-kanban.dev/agents/a6bb038c4226a87f
* test: add integration tests for team invite service and routes
Cover createInviteLink, getInviteLinkInfo, acceptInviteLink, and
listPendingInvitations service functions. Add route tests for all
public and authenticated team invite endpoints (invite-info, invite-link,
invitations list, and join). Add team_invite_links table to test setup.
Agent-Profile: https://agent-kanban.dev/agents/a6bb038c4226a87f
* style: fix biome lint in team invite test files
Agent-Profile: https://agent-kanban.dev/agents/a6bb038c4226a87f
* fix: resolve CodeQL open-redirect and missing coverage issues
- Validate redirect param in sign-in.tsx is a same-origin relative path
to prevent open redirect and javascript: URI XSS (CodeQL alerts)
- Spread defaultRoles (owner/admin/member) when configuring custom roles
in organization plugin so built-in roles retain their permissions
- Add integration tests for sendInvitationEmail callback to cover
buildInvitationEmailHtml and the email dispatch path in auth.ts
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix: use URL constructor to sanitize redirect param in sign-in
Replace regex check with URL constructor origin validation so CodeQL's
dataflow analysis can confirm the value is same-origin before it reaches
window.location.href (resolves js/xss and js/client-side-unvalidated-url-redirection alerts).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Bob <aibob@mails.agent-kanban.dev>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat: remove Name field from registration form
- Remove name from signUpSchema; send empty string to satisfy DB NOT NULL
- Reorder sign-up form: Email → Username → Password → Invite Code
- createPersonalOrg falls back to username when name is empty
- Add username to UserWithOrg type (server and frontend), typed as string
- Sidebar and admin users table show username when name is empty
- Update all e2e helpers and specs to remove Name field interactions
Agent-Profile: https://agent-kanban.dev/agents/a6bb038c4226a87f
* fix: update signUpSchema tests to match new schema (email, username, password)
Remove name field tests; add username validation tests.
Agent-Profile: https://agent-kanban.dev/agents/a6bb038c4226a87f
---------
Co-authored-by: Bob <aibob@mails.agent-kanban.dev>
user.id is not available in the before hook (generated after). Split
the logic: validate in before (reject invalid codes early), redeem
in after (user.id is now set). Also read inviteCode from context.body
instead of request.clone().json().
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
additionalFields tries to persist inviteCode to the user table, which
fails because there's no invite_code column. Instead, read it from
the request body via the databaseHooks context parameter.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Enforce signup gating via auth_signup_mode system option (open/invite_only/closed).
Invite codes are atomically redeemed during signup. Email verification is
conditionally enabled when an email provider is configured.
Agent-Profile: https://agent-kanban.dev/agents/a6bb038c4226a87f
Co-authored-by: Bob <aibob@mails.agent-kanban.dev>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
* feat(auth): add dynamic OAuth provider system
Admin can configure OAuth/OIDC providers in the database via API.
All 35 built-in better-auth providers are registered as async functions
that read config from system_options at runtime. Custom OIDC providers
use the genericOAuth plugin with configs loaded at auth init time.
New endpoints:
- GET /api/auth-providers (public, enabled only, no secrets)
- GET /api/auth-providers/admin (admin, all configs, masked secrets)
- PUT /api/auth-providers/admin/:providerId (admin, upsert)
- DELETE /api/auth-providers/admin/:providerId (admin, remove)
Agent-Profile: https://agent-kanban.dev/agents/a6bb038c4226a87f
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: async createTestApp compat in email and invite test files
createAuth became async in the OAuth PR, which made createTestApp async.
Email and invite code test files need await + Awaited<> type wrappers.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Bob <aibob@mails.agent-kanban.dev>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
* feat(auth): add better-auth username plugin
Enable username-based registration and sign-in by adding the username
plugin to both server and client auth configurations. Adds username
and display_username columns to the user table via migration.
Agent-Profile: https://agent-kanban.dev/agents/a6bb038c4226a87f
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* test(auth): add username plugin tests and fix test setup
Add schema and integration tests for the username plugin. Fix the
in-memory SQLite test setup to include username columns so existing
auth tests don't break.
Agent-Profile: https://agent-kanban.dev/agents/a6bb038c4226a87f
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Bob <aibob@mails.agent-kanban.dev>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
New users get a 10MB default storage quota (configurable via admin settings).
Admin can set the default in Settings with MB/GB unit selector. Added db:generate,
db:migrate, and db:reset scripts; dev server now reads .dev.vars automatically.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Two related changes to remove a per-request DB lookup and harden the
personal-org semantics:
1. Add `databaseHooks.session.create.before` in server/auth.ts to pin
`activeOrganizationId` to the user's personal org at session
creation. The middleware at server/middleware/auth.ts:27 already
falls back to findPersonalOrg when the session has no activeOrgId,
but that fallback fires on every request during the 5-minute
session cookie cache window. Baking it into the session row means
the cached cookie already carries the right value.
2. Refactor `findPersonalOrg` in server/services/org.ts from a
metadata-JSON LIKE match to a slug lookup plus membership JOIN:
- Slug lookup uses `personal-${userId}` — the exact string
createPersonalOrg writes at signup. UNIQUE + indexed, no
reliance on V8's JSON.stringify output format.
- INNER JOIN on member preserves the old semantic that losing
membership (admin revokes) orphans the user from the personal
org even if the org row still exists.
Tests updated to match the new contract: three vestigial tests that
exercised JSON metadata parsing (now dead code) are removed; two
fixtures updated to write the correct slug; one new test locks in
"org exists but member row deleted → null".
The quotas.test.ts:156 scenario ("returns 404 when user has no org"
by manually deleting the member row) keeps working because the JOIN
still enforces membership.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
verifyPassword previously returned false when the stored hash lacked a
salt or key segment, which silently masqueraded as "wrong password" and
hid real data integrity problems from the operator. A corrupt credential
in the user table should surface loudly so it can be investigated, not
look like a typo in the user's password.
Throw with a descriptive error instead. Per the project principle:
"Errors must be handled explicitly. Never ignore, silence, or downgrade
an error just to keep things running."
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The previous implementation ran the first-user-to-admin promotion in
databaseHooks.user.create.after: INSERT fired first with role='user',
then the after hook ran UPDATE to set role='admin'. But better-auth
built the session cookie from the in-memory user object BEFORE the
after hook ran, so the very first sign-up always walked away with a
session claiming role='user' — the only workaround was to log out and
log back in to force a fresh DB read.
Move the logic into the before hook and return `{ data: { ...user,
role: 'admin' } }` when the user table is empty. The INSERT now
writes 'admin' directly and the session cookie is born correct.
Also extract `isFirstUser(db)` using Drizzle's `count()` query builder
instead of the previous raw SQL, and delete the now-redundant
`promoteFirstUserToAdmin` and `setupNewUser` helpers.
Also fix vitest.cloudflare.config.ts: after wrangler.toml was
restructured in commit 4d0511b to put D1 bindings under
[env.production]/[env.preview], the CF vitest pool had no environment
specified and read no bindings, breaking all 5 cf-test files with
"env.DB is not a D1Database". Pin the pool to `environment: 'production'`
so tests receive a complete binding surface (miniflare still creates
an ephemeral in-memory D1 regardless, so no data risk).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Better-auth's default password hasher is @noble/hashes scrypt (pure JS),
which consumes ~100-200ms of CPU per call. Cloudflare Workers' free tier
caps each request at 10ms of JS CPU time, so sign-up/sign-in consistently
fails with error 1102 after the initial cold-start burst budget runs out.
See better-auth/better-auth#8860 for the upstream bug.
Override emailAndPassword.password.hash/verify with node:crypto.scryptSync.
The native OpenSSL implementation runs in ~ms of wall time and is counted
as I/O rather than JS CPU time on CF Workers, so it does not touch the
CPU budget. Works identically on the Node/Docker entry because
node:crypto is native there too.
Verified end-to-end on https://af9a6fdc.zpan.pages.dev: 5 sequential
signups + 5 sequential signins all returned HTTP 200 (previously 4/4
consecutive signups hit error 1102 on the same deployment).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>