diff --git a/apps/sim/app/api/table/[tableId]/route.ts b/apps/sim/app/api/table/[tableId]/route.ts
index 5fe0f4add09..ec78330932a 100644
--- a/apps/sim/app/api/table/[tableId]/route.ts
+++ b/apps/sim/app/api/table/[tableId]/route.ts
@@ -18,6 +18,7 @@ import {
} from '@/lib/table'
import { getWorkspaceTableLimits } from '@/lib/table/billing'
import { TABLE_LOCK_FLAGS, TABLE_LOCK_KINDS } from '@/lib/table/types'
+import { getWorkspaceWithOwner } from '@/lib/workspaces/permissions/utils'
import {
accessError,
checkAccess,
@@ -154,8 +155,20 @@ export const PATCH = withRouteHandler(
const flag = TABLE_LOCK_FLAGS[kind]
return validated.locks?.[flag] === true && !table.locks[flag]
})
- if (enablesALock && !(await isFeatureEnabled('table-locks'))) {
- return NextResponse.json({ error: 'Table locks are not enabled' }, { status: 403 })
+ if (enablesALock) {
+ // Resolve with the same context the page uses to decide whether to
+ // show the panel — keyed on the workspace's host organization, not
+ // the viewer's active one. Without it an org- or user-targeted
+ // rollout would open the panel and then 403 on save. Looked up only
+ // on the enabling path, so an unlock never pays for it.
+ const workspace = await getWorkspaceWithOwner(table.workspaceId)
+ const enabled = await isFeatureEnabled('table-locks', {
+ userId: authResult.userId,
+ orgId: workspace?.organizationId ?? undefined,
+ })
+ if (!enabled) {
+ return NextResponse.json({ error: 'Table locks are not enabled' }, { status: 403 })
+ }
}
const adminResult = await checkAccess(tableId, authResult.userId, 'admin')
if (!adminResult.ok) {
@@ -164,7 +177,7 @@ export const PATCH = withRouteHandler(
{ status: 403 }
)
}
- await updateTableLocks(tableId, validated.locks, authResult.userId, requestId)
+ await updateTableLocks(tableId, validated.locks, authResult.userId, requestId, request)
}
if (validated.name !== undefined) {
diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/lock-copy.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/lock-copy.ts
index 359bcd25178..f87a5bdbcd1 100644
--- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/lock-copy.ts
+++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/lock-copy.ts
@@ -60,10 +60,16 @@ export function describeLocks(locks: TableLocks): { name: string; detail: string
if (locked.length === LOCK_FIELDS.length) {
return { name: 'Read-only', detail: 'no one can change this table’s rows or columns.' }
}
- // Only the exact three-lock shape is Append-only — with the schema locked too
- // the chip would claim columns are mutable when they aren't.
- if (!locks.insertLocked && locks.updateLocked && locks.deleteLocked && !locks.schemaLocked) {
- return { name: 'Append-only', detail: 'rows can be added, but not edited or deleted.' }
+ // Append-only describes the row semantics — adding is the only thing left.
+ // A schema lock on top doesn't change that, so it keeps the name and is
+ // called out in the detail rather than demoted to the generic case.
+ if (!locks.insertLocked && locks.updateLocked && locks.deleteLocked) {
+ return {
+ name: 'Append-only',
+ detail: locks.schemaLocked
+ ? 'rows can be added, but not edited or deleted, and columns are locked.'
+ : 'rows can be added, but not edited or deleted.',
+ }
}
return { name: 'Locked', detail: `${locked.join(', ')} locked.` }
}
diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/page.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/page.tsx
index 9f3c382c3ff..c35d081c954 100644
--- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/page.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/page.tsx
@@ -1,5 +1,8 @@
import { Suspense } from 'react'
import type { Metadata } from 'next'
+import { getSession } from '@/lib/auth'
+import { isFeatureEnabled } from '@/lib/core/config/feature-flags'
+import { getWorkspaceHostContextForViewer } from '@/lib/workspaces/host-context'
import TableLoading from '@/app/workspace/[workspaceId]/tables/[tableId]/loading'
import { Table } from './table'
@@ -7,15 +10,35 @@ export const metadata: Metadata = {
title: 'Table',
}
+interface TablePageProps {
+ params: Promise<{ workspaceId: string }>
+}
+
/**
* Table-detail page entry. `Table` reads URL query params via nuqs (which uses
* `useSearchParams` internally), so it must sit under a Suspense boundary. The
* fallback renders the real chrome so a suspend never shows a blank frame.
+ *
+ * The lock flag is resolved here rather than mirrored into a `NEXT_PUBLIC_` var:
+ * gating lives in AppConfig, which has no client counterpart, so resolving it
+ * server-side is the only way its org/user clauses reach the UI. The org is the
+ * workspace's host organization, not the viewer's active one — the same key the
+ * PATCH gate uses, so the panel can't open onto a Save that 403s. Both
+ * `getSession` and the host context are request-memoized, so this reuses the
+ * layout's reads.
*/
-export default function TablePage() {
+export default async function TablePage({ params }: TablePageProps) {
+ const [{ workspaceId }, session] = await Promise.all([params, getSession()])
+ const userId = session?.user?.id
+ const host = userId ? await getWorkspaceHostContextForViewer(workspaceId, userId) : null
+ const tableLocksEnabled = await isFeatureEnabled('table-locks', {
+ userId,
+ orgId: host?.hostOrganizationId ?? undefined,
+ })
+
return (
}>
-
+
)
}
diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx
index 006c74c8307..1c0daa5fed7 100644
--- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx
@@ -8,7 +8,6 @@ import { useParams, useRouter } from 'next/navigation'
import { useQueryStates } from 'nuqs'
import { usePostHog } from 'posthog-js/react'
import type { RunLimit, RunMode } from '@/lib/api/contracts/tables'
-import { getEnv, isTruthy } from '@/lib/core/config/env'
import { captureEvent } from '@/lib/posthog/client'
import type {
ColumnDefinition,
@@ -78,14 +77,6 @@ import { generateColumnName } from './utils'
const logger = createLogger('Table')
-/**
- * Client mirror of the server's `table-locks` flag. Hides the settings entry
- * point when locks can't be set, so the panel never opens onto a Save that
- * 403s. An already-locked table still shows its state regardless, so the
- * gating stays legible if the flag is turned off after locks were applied.
- */
-const tableLocksEnabled = isTruthy(getEnv('NEXT_PUBLIC_TABLE_LOCKS'))
-
/** Blocked-action toasts carry a button, so they linger past the 5s default. */
const BLOCKED_TOAST_MS = 8000
@@ -96,6 +87,13 @@ interface TableProps {
/** Identifiers — only set in embedded mode. Page mode reads from `useParams()`. */
workspaceId?: string
tableId?: string
+ /**
+ * Whether an admin may CHANGE locks, resolved server-side by the page (the
+ * flag's gating lives in AppConfig and has no client counterpart). Defaults
+ * to false so embedded renders, which have no server resolution, fail closed
+ * — enforcement of stored locks is unaffected either way.
+ */
+ tableLocksEnabled?: boolean
}
/**
@@ -152,6 +150,7 @@ export function Table({
embedded,
workspaceId: propWorkspaceId,
tableId: propTableId,
+ tableLocksEnabled = false,
}: TableProps = {}) {
const params = useParams()
const router = useRouter()
@@ -644,16 +643,15 @@ export function Table({
const headerActions = useMemo(() => {
if (!tableData) return undefined
+ // Header space is for state, not for settings: the chip appears only once
+ // something is actually locked, and names the mode so it reads at a glance.
+ // Reaching the panel on an unlocked table is the dropdown's job.
const anyLocked = lockedNouns(tableData.locks).length > 0
- // Name the mode when locked so the state is legible on open; admins get the
- // entry point on an unlocked table only where the feature is enabled —
- // otherwise the panel opens and Save 403s against the server-side gate.
- const lockLabel = anyLocked ? describeLocks(tableData.locks).name : 'Lock settings'
return [
- ...(anyLocked || (userPermissions.canAdmin && tableLocksEnabled)
+ ...(anyLocked
? [
{
- label: lockLabel,
+ label: describeLocks(tableData.locks).name,
icon: Lock,
onClick: () =>
userPermissions.canAdmin ? setShowLockSettings(true) : showBlockedToast('status'),
diff --git a/apps/sim/lib/core/config/env.ts b/apps/sim/lib/core/config/env.ts
index 5c135e7d12e..76b87384171 100644
--- a/apps/sim/lib/core/config/env.ts
+++ b/apps/sim/lib/core/config/env.ts
@@ -572,7 +572,6 @@ export const env = createEnv({
NEXT_PUBLIC_SSO_ENABLED: z.boolean().optional(), // Enable SSO login UI components
NEXT_PUBLIC_ACCESS_CONTROL_ENABLED: z.boolean().optional(), // Enable access control (permission groups) on self-hosted
NEXT_PUBLIC_CUSTOM_BLOCKS_ENABLED: z.boolean().optional(), // Enable custom blocks (deploy-as-block) settings on self-hosted
- NEXT_PUBLIC_TABLE_LOCKS: z.boolean().optional(), // Surface the per-table mutation-lock settings UI (client mirror of the server-side TABLE_LOCKS gate)
NEXT_PUBLIC_WHITELABELING_ENABLED: z.boolean().optional(), // Enable whitelabeling on self-hosted (bypasses hosted requirements)
NEXT_PUBLIC_AUDIT_LOGS_ENABLED: z.boolean().optional(), // Enable audit logs on self-hosted (bypasses hosted requirements)
NEXT_PUBLIC_DATA_RETENTION_ENABLED: z.boolean().optional(), // Enable data retention settings on self-hosted (bypasses hosted requirements)
@@ -614,7 +613,6 @@ export const env = createEnv({
NEXT_PUBLIC_SSO_ENABLED: process.env.NEXT_PUBLIC_SSO_ENABLED,
NEXT_PUBLIC_ACCESS_CONTROL_ENABLED: process.env.NEXT_PUBLIC_ACCESS_CONTROL_ENABLED,
NEXT_PUBLIC_CUSTOM_BLOCKS_ENABLED: process.env.NEXT_PUBLIC_CUSTOM_BLOCKS_ENABLED,
- NEXT_PUBLIC_TABLE_LOCKS: process.env.NEXT_PUBLIC_TABLE_LOCKS,
NEXT_PUBLIC_WHITELABELING_ENABLED: process.env.NEXT_PUBLIC_WHITELABELING_ENABLED,
NEXT_PUBLIC_AUDIT_LOGS_ENABLED: process.env.NEXT_PUBLIC_AUDIT_LOGS_ENABLED,
NEXT_PUBLIC_DATA_RETENTION_ENABLED: process.env.NEXT_PUBLIC_DATA_RETENTION_ENABLED,
diff --git a/apps/sim/lib/table/service.ts b/apps/sim/lib/table/service.ts
index 3a76842f2ed..ef55b56ca55 100644
--- a/apps/sim/lib/table/service.ts
+++ b/apps/sim/lib/table/service.ts
@@ -27,6 +27,8 @@ import type { DbTransaction } from '@/lib/table/planner'
import { setTableTxTimeouts } from '@/lib/table/tx'
import {
type CreateTableData,
+ TABLE_LOCK_FLAGS,
+ TABLE_LOCK_KINDS,
type TableDefinition,
type TableLocks,
type TableMetadata,
@@ -627,9 +629,13 @@ export async function updateTableLocks(
tableId: string,
partial: Partial,
actingUserId: string,
- requestId: string
+ requestId: string,
+ /** Forwarded to the audit record for IP / user-agent capture. */
+ request?: { headers: { get(name: string): string | null } }
): Promise {
+ let previousLocks: TableLocks = UNLOCKED_TABLE_LOCKS
const updated = await withLockedTable(tableId, async (table, trx) => {
+ previousLocks = table.locks
const nextLocks: TableLocks = { ...table.locks, ...partial }
const now = new Date()
await trx
@@ -639,6 +645,18 @@ export async function updateTableLocks(
return { ...table, locks: nextLocks, updatedAt: now }
})
+ // Name the transitions in the description so the audit list is readable
+ // without expanding metadata — "who locked my production table" is the
+ // question this feature exists to answer.
+ const flipped = TABLE_LOCK_KINDS.filter(
+ (kind) => previousLocks[TABLE_LOCK_FLAGS[kind]] !== updated.locks[TABLE_LOCK_FLAGS[kind]]
+ )
+ const description = flipped.length
+ ? `Table locks changed: ${flipped
+ .map((kind) => `${kind} ${updated.locks[TABLE_LOCK_FLAGS[kind]] ? 'locked' : 'unlocked'}`)
+ .join(', ')}`
+ : 'Updated table locks (no change)'
+
recordAudit({
workspaceId: updated.workspaceId,
actorId: actingUserId,
@@ -646,8 +664,9 @@ export async function updateTableLocks(
resourceType: AuditResourceType.TABLE,
resourceId: tableId,
resourceName: updated.name,
- description: 'Updated table locks',
- metadata: { op: 'update_locks', locks: updated.locks },
+ description,
+ metadata: { op: 'update_locks', before: previousLocks, after: updated.locks },
+ ...(request ? { request } : {}),
})
await appendTableEvent({ kind: 'definition', tableId, reason: 'locks' }).catch((error) => {