Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
dcc374e
fix(THU-549): clear auth token after signOut so server can revoke ses…
raivieiraadriano92 Jun 15, 2026
7c77996
fix(THU-549): clear registry before db-closing so reloaded tabs land …
raivieiraadriano92 Jun 15, 2026
71a0820
fix(THU-549): guard LogoutModal handleLogout against rapid double-click
raivieiraadriano92 Jun 15, 2026
8e33ff7
fix(THU-549): friendlier ZodError for unset/invalid SERVER_ID
raivieiraadriano92 Jun 15, 2026
5d89e0a
fix(THU-549): remove Cloud URL dev-settings input (use ModePicker flo…
raivieiraadriano92 Jun 15, 2026
1dfe503
fix(THU-552): preserve existing membership role on promote-on-insert
raivieiraadriano92 Jun 15, 2026
313f3a3
fix(THU-552): delete pending row by (workspace_id, email) after promo…
raivieiraadriano92 Jun 15, 2026
1df0233
fix(THU-552): skip onCreated when create-workspace modal dismissed mi…
raivieiraadriano92 Jun 15, 2026
f6ad6a5
fix(THU-554): preserve slug/icon on PUT when payload omits them
raivieiraadriano92 Jun 15, 2026
aaec70b
fix(THU-554): reflect remote workspace updates into settings form
raivieiraadriano92 Jun 15, 2026
74cb74f
fix(THU-554): append random suffix to duplicate workspace slug
raivieiraadriano92 Jun 15, 2026
c2534e7
fix(THU-555): resolve workspace permission state when user has no mem…
raivieiraadriano92 Jun 15, 2026
f1f0e18
fix(THU-555): gate member actions on granular permission keys
raivieiraadriano92 Jun 15, 2026
43a9e77
fix(THU-551): expose URL workspace id immediately + collapse chat-id …
raivieiraadriano92 Jun 15, 2026
23c0937
fix(THU-550): wrap BrowserRouter in ErrorBoundary so bootstrap throws…
raivieiraadriano92 Jun 15, 2026
bccfce4
chore: strip PR-ref noise from review-followup comments
raivieiraadriano92 Jun 15, 2026
c2dc02e
fix(THU-578): reset isReady before chat session eviction on workspace…
raivieiraadriano92 Jun 15, 2026
71816a0
fix(THU-553): dedupe ModePicker validation between blur and Continue
raivieiraadriano92 Jun 16, 2026
26f94fd
fix(THU-552): force ctx.userId for invitedByUserId in pending members…
raivieiraadriano92 Jun 16, 2026
1da6d76
fix(THU-552): reject malformed emails on pending membership writes
raivieiraadriano92 Jun 16, 2026
860a3c4
fix(THU-578): persist selectedAgent against session.workspaceId not t…
raivieiraadriano92 Jun 16, 2026
bd38c0a
fix(THU-556): hide chat-skills-bar when no edit perm and no pinned chips
raivieiraadriano92 Jun 16, 2026
8d68479
docs: refresh workspace-memberships handler docstring for permission-…
raivieiraadriano92 Jun 16, 2026
95febe9
test(THU-578): seed workspace context in LinkPreviewWidget fallback t…
raivieiraadriano92 Jun 16, 2026
7299ce1
fix(THU-553): reset isValidating on SET_URL to unstick Continue after…
raivieiraadriano92 Jun 16, 2026
f59acc1
fix(THU-553): reset stage to picker on stale-URL bail-out in handleCo…
raivieiraadriano92 Jun 16, 2026
600d434
fix(THU-549): pass captured serverId to handleFullWipe after registry…
raivieiraadriano92 Jun 16, 2026
3ea931e
fix(THU-555): align pending-row gates + block admin escalation via in…
raivieiraadriano92 Jun 16, 2026
4708f8d
refactor(THU-555): use isResolved for membership loading distinction
raivieiraadriano92 Jun 16, 2026
b9e00f4
refactor(THU-555): assign openRef in render body, drop useEffect
raivieiraadriano92 Jun 16, 2026
13f66a8
fix(THU-555): widen duplicate-slug suffix to 8 chars for collision re…
raivieiraadriano92 Jun 16, 2026
d32e10e
docs(THU-555): refresh stale comments referencing removed manage_memb…
raivieiraadriano92 Jun 16, 2026
325c52a
fix(THU-555): mirror serverUrl ref synchronously in mode-picker onChange
raivieiraadriano92 Jun 16, 2026
7736648
fix(THU-555): sync pending memberships to all workspace members
raivieiraadriano92 Jun 16, 2026
b8b6f8b
fix(THU-555): require change_roles for membership PUT that changes ex…
raivieiraadriano92 Jun 16, 2026
bd4a338
fix(THU-549): keep getAuthToken resolvable through signOut after regi…
raivieiraadriano92 Jun 16, 2026
5c353cb
fix(THU-555): add last-admin guard to membership PUT apply path
raivieiraadriano92 Jun 16, 2026
3b9607c
fix(THU-555): gate pending-row Admin option on change_roles
raivieiraadriano92 Jun 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions backend/src/auth/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -212,8 +212,10 @@ export const createAuth = (database: typeof DbType, emailDeps: AuthEmailDeps = {
}),
// Promote any pending memberships invited by email. The personal workspace
// itself is FE-created (uploaded via PowerSync with a deterministic id), so
// this hook only handles the admin-only pending-membership flow that the FE
// can't see. Skipped for anonymous users — anon never receives invites.
// this hook only handles the cross-workspace promotion flow — a brand-new
// user isn't a member of anything yet, so no FE client can see (let alone
// act on) the invite at signup time. Skipped for anonymous users — anon
// never receives invites.
after: async (createdUser) => {
const isAnonymous = (createdUser as { isAnonymous?: boolean }).isAnonymous === true
if (isAnonymous) {
Expand Down
27 changes: 27 additions & 0 deletions backend/src/config/settings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,33 @@ describe('Config Settings', () => {
})
})

describe('SERVER_ID validation', () => {
const savedServerId = process.env.SERVER_ID

afterEach(() => {
if (savedServerId !== undefined) {
process.env.SERVER_ID = savedServerId
} else {
delete process.env.SERVER_ID
}
clearSettingsCache()
})

it('surfaces a setup-pointing error when SERVER_ID is unset', () => {
delete process.env.SERVER_ID
clearSettingsCache()

expect(() => getSettings()).toThrow(/SERVER_ID env var must be set.*make doctor/)
})

it('surfaces a UUID-format error when SERVER_ID is set but not a UUID', () => {
process.env.SERVER_ID = 'not-a-uuid'
clearSettingsCache()

expect(() => getSettings()).toThrow(/SERVER_ID must be a valid UUID/)
})
})

describe('CORS default security', () => {
const corsEnvKeys = ['CORS_ORIGINS'] as const

Expand Down
10 changes: 9 additions & 1 deletion backend/src/config/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,15 @@ const settingsSchema = z
// key the trust-domain registry (auth token, device ID, encryption keys, DB filename
// are all namespaced by this). No default — TS-enforced to prevent ID duplication
// across deployments. `make doctor` auto-generates one for local dev.
serverId: z.string().uuid(),
//
// Custom messages mirror the BETTER_AUTH_SECRET ergonomics: a raw `Expected
// string, received undefined` ZodError doesn't tell a fresh dev what to do.
serverId: z
.string({
error:
'SERVER_ID env var must be set to a stable per-deployment UUID. Run `make doctor` to auto-generate one for local dev.',
})
.uuid({ error: 'SERVER_ID must be a valid UUID.' }),

// API Keys
fireworksApiKey: z.string().default(''),
Expand Down
72 changes: 70 additions & 2 deletions backend/src/dal/workspaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,9 @@ import { v7 as uuidv7 } from 'uuid'
* Called from the Better Auth post-user-create hook. The personal workspace
* itself is FE-created (uploaded via PowerSync with a deterministic id from
* `shared/workspaces.ts`) — the BE no longer creates one here. Pending
* promotion stays server-side because pending rows live in an admin-only
* sync bucket and FE can't see other users' invites until they're memberships.
* promotion stays server-side because a brand-new user isn't a member of any
* workspace yet, so no FE client can see (or act on) the pending invite at
* signup time.
*
* Skipped for anonymous users — anon never receives pending invites.
*/
Expand Down Expand Up @@ -251,6 +252,30 @@ export const getMembershipById = async (
return rows[0] ?? null
}

/**
* Look up a membership by `(workspace_id, user_id)` — the unique constraint
* that `upsertMembership` collides on. Upload-handler validation uses this to
* detect when a PUT would effectively change an existing role (treated as a
* PATCH for auth purposes).
*/
export const getMembershipByWorkspaceAndUser = async (
database: typeof DbType,
workspaceId: string,
userId: string,
): Promise<MembershipRow | null> => {
const rows = await database
.select({
id: workspaceMembershipsTable.id,
workspaceId: workspaceMembershipsTable.workspaceId,
userId: workspaceMembershipsTable.userId,
role: workspaceMembershipsTable.role,
})
.from(workspaceMembershipsTable)
.where(and(eq(workspaceMembershipsTable.workspaceId, workspaceId), eq(workspaceMembershipsTable.userId, userId)))
.limit(1)
return rows[0] ?? null
}

export const getPendingMembershipById = async (database: typeof DbType, id: string): Promise<PendingRow | null> => {
const rows = await database
.select({
Expand Down Expand Up @@ -409,6 +434,23 @@ export const upsertMembership = async (database: typeof DbType, input: Membershi
})
}

/**
* Insert a membership row only if no row with the same `(workspace_id, user_id)`
* already exists. Used by the promote-on-insert path in the pending-membership
* upload handler: an invite for an email that already belongs to a member must
* not overwrite that member's existing role (otherwise an invite for an admin's
* own email would downgrade them to whatever role the invite carried). Mirrors
* the `promotePendingMemberships` DO-NOTHING semantics for the signup path.
*/
export const insertMembershipIfMissing = async (database: typeof DbType, input: MembershipInput): Promise<void> => {
await database
.insert(workspaceMembershipsTable)
.values(input)
.onConflictDoNothing({
target: [workspaceMembershipsTable.workspaceId, workspaceMembershipsTable.userId],
})
}

/**
* Mirrors a user's current display info onto every one of their membership rows.
* Called from the Better Auth `update.after` hook so name/email changes propagate
Expand Down Expand Up @@ -510,6 +552,32 @@ export const deletePendingMembership = async (database: typeof DbType, id: strin
return rows.length
}

/**
* Deletes the pending row for `(workspace_id, email)`. Used by the
* promote-on-insert path in the upload handler: when `upsertPendingMembership`
* conflicts on the `(workspace_id, email)` unique constraint, Postgres keeps
* the existing row's id, so a delete keyed on the upload's `op.id` would no-op
* and leave a stale pending invite behind for someone who is now a real
* member. Email is normalized to match `upsertPendingMembership`'s storage.
*/
export const deletePendingMembershipByWorkspaceAndEmail = async (
database: typeof DbType,
workspaceId: string,
email: string,
): Promise<number> => {
const normalizedEmail = normalizeEmail(email)
const rows = await database
.delete(workspacePendingMembershipsTable)
.where(
and(
eq(workspacePendingMembershipsTable.workspaceId, workspaceId),
eq(workspacePendingMembershipsTable.email, normalizedEmail),
),
)
.returning()
return rows.length
}

export type WorkspacePermissionInput = {
id: string
workspaceId: string
Expand Down
10 changes: 10 additions & 0 deletions backend/src/lib/email.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,13 @@
* - Trims whitespace
*/
export const normalizeEmail = (email: string) => email.toLowerCase().trim()

/**
* Format check (same regex as the FE's `isValidEmailFormat`) — guards
* upload-handler inserts against junk strings before they hit the DB. Run
* against the normalized form so case/whitespace don't sneak past.
*/
const emailRegex =
/^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+$/

export const isValidEmailFormat = (email: string): boolean => emailRegex.test(normalizeEmail(email))
23 changes: 22 additions & 1 deletion backend/src/powersync/upload-handlers/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */

import type { HandlerResult } from './types'
import { getRequiredRoleForPermission, getUserRoleInWorkspace } from '@/dal/workspaces'
import { permissionAllows, type WorkspacePermissionKey } from '@shared/workspaces'
import type { HandlerResult, UploadTx } from './types'

/** Column names Drizzle declares as `timestamp(...)`; JSON sends them as ISO strings. */
const timestampDbColumns = new Set(['deleted_at', 'last_seen', 'created_at', 'revoked_at', 'updated_at'])
Expand Down Expand Up @@ -42,3 +44,22 @@ export const reject = (rejectionClass: 'permanent' | 'transient', code: string):
class: rejectionClass,
code,
})

/**
* Resolves the caller's role + the configured permission's required role and
* returns whether the op is allowed. Defaults `required_role` to `'admin'`
* when no `workspace_permissions` row exists for the key (Decision 11) so an
* unconfigured workspace stays admin-only. Shared by every handler that gates
* writes on `workspace_permissions` — keep the lookup in one place so the
* default-to-admin policy can't drift between tables.
*/
export const callerSatisfiesPermission = async (
tx: UploadTx,
workspaceId: string,
userId: string,
permissionKey: WorkspacePermissionKey,
): Promise<boolean> => {
const required = (await getRequiredRoleForPermission(tx, workspaceId, permissionKey)) ?? 'admin'
const userRole = await getUserRoleInWorkspace(tx, workspaceId, userId)
return permissionAllows(userRole, required)
}
Loading
Loading