Skip to content

feat: add zane-operator preview DB flow and harden prod Docker/Postgres build path - #335

Merged
redeyecz merged 14 commits into
masterfrom
feat/zaneops_db_http
Feb 25, 2026
Merged

feat: add zane-operator preview DB flow and harden prod Docker/Postgres build path#335
redeyecz merged 14 commits into
masterfrom
feat/zaneops_db_http

Conversation

@redeyecz

@redeyecz redeyecz commented Feb 24, 2026

Copy link
Copy Markdown
Collaborator

This PR adds the zane-operator preview DB workflow and hardens Medusa/Postgres + Docker production behavior for schema-based deployments.
It also optimizes prod build caching and splits Make targets into cached vs no-cache variants.

What Changed

  • Added apps/zane-operator service (HTTP/auth/config/db/bootstrap handlers + Docker integration).
  • Wired zane-operator and bootstrap role flow into Compose/env config.
  • Added/updated Postgres bootstrap & verification scripts for strict role/grant setup.
  • Updated Medusa DB/runtime setup for non-public schema behavior and safer initialization flow.
  • Improved prod Dockerfiles (medusa-be, n1):
    • Corepack/pnpm available in image
    • BuildKit cache mounts
    • lockfile-based pnpm fetch
    • offline installs in build steps
    • kept second n1 install (documented) to avoid workspace-injection relink regressions
  • Updated prod Compose overrides:
    • explicit volumes: !reset [] for medusa-be and n1 (prevents inherited dev bind mounts)
    • explicit NODE_ENV=production for medusa-be
  • Refactored Makefile:
    • make prod (cached build)
    • make prod-no-cache
    • shared DRY prod logic via prod-run and compose vars

Why

  • Fixes/avoids schema-specific migration/link-sync failures under stricter Postgres privileges.
  • Keeps prod builds deterministic while improving cache reuse.
  • Ensures prod compose does not accidentally inherit dev mount behavior.

Validation

  • docker compose -f docker-compose.yaml -f docker-compose.prod.yaml config --format json confirms no medusa-be/n1 volumes in prod.
  • docker compose -f docker-compose.yaml -f docker-compose.prod.yaml build n1 medusa-be completes successfully.
  • make -n prod and make -n prod-no-cache differ only by --no-cache build flags.

Summary by CodeRabbit

  • New Features

    • Added a preview database operator service with protected API and CLI for provisioning and teardown of PR preview databases; startup health endpoints and role lifecycle management.
    • Automatic initial superadmin and publishable key provisioning.
  • Documentation

    • Expanded READMEs and a comprehensive operator/agent guide covering DB bootstrap, role/grant policies, verification and deployment flows.
  • Chores

    • Reworked compose/Make workflows, Dockerfiles, env templates and added Postgres bootstrap, verification and utility scripts and a preview deploy workflow.

@semanticdiff-com

semanticdiff-com Bot commented Feb 24, 2026

Copy link
Copy Markdown

Review changes with  SemanticDiff

Changed Files
File Status
  docker-compose.prod.yaml  32% smaller
  apps/medusa-be/src/scripts/output-publishable-key.ts  22% smaller
  docker-compose.yaml  22% smaller
  apps/medusa-be/package.json  19% smaller
  .env.docker Unsupported file format
  .github/workflows/deploy-zaneops-preview.yml  0% smaller
  Makefile Unsupported file format
  README.md Unsupported file format
  apps/medusa-be/.env.template Unsupported file format
  apps/medusa-be/README.md Unsupported file format
  apps/medusa-be/medusa-config.ts  0% smaller
  apps/medusa-be/src/scripts/create-initial-superadmin.ts  0% smaller
  apps/medusa-be/src/utils/db.ts  0% smaller
  apps/zane-operator/.env.example Unsupported file format
  apps/zane-operator/AGENTS.md Unsupported file format
  apps/zane-operator/README.md Unsupported file format
  apps/zane-operator/package.json  0% smaller
  apps/zane-operator/project.json  0% smaller
  apps/zane-operator/src/auth.ts  0% smaller
  apps/zane-operator/src/bootstrap-role.ts  0% smaller
  apps/zane-operator/src/cli.ts  0% smaller
  apps/zane-operator/src/config.ts  0% smaller
  apps/zane-operator/src/db.ts  0% smaller
  apps/zane-operator/src/handlers/ensure-preview-db.ts  0% smaller
  apps/zane-operator/src/handlers/health.ts  0% smaller
  apps/zane-operator/src/handlers/teardown-preview-db.ts  0% smaller
  apps/zane-operator/src/http.ts  0% smaller
  apps/zane-operator/src/main.ts  0% smaller
  apps/zane-operator/src/pg-utils.ts  0% smaller
  apps/zane-operator/tsconfig.json  0% smaller
  docker/development/medusa-be/Dockerfile Unsupported file format
  docker/development/n1/Dockerfile Unsupported file format
  docker/development/postgres/initdb/01-zane-role-bootstrap.sh Unsupported file format
  docker/development/zane-operator/Dockerfile Unsupported file format
  scripts/apply-postgres-role-bootstrap.sh Unsupported file format
  scripts/apply-zane-operator-role-bootstrap.sh Unsupported file format
  scripts/verify-postgres-grants.sh Unsupported file format
  zerops.yml  0% smaller

@vercel

vercel Bot commented Feb 24, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
new-engine-ui-storybook Ready Ready Preview, Comment Feb 25, 2026 4:12pm

@coderabbitai

coderabbitai Bot commented Feb 24, 2026

Copy link
Copy Markdown
Contributor

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Introduce a new Bun-based zane-operator service with HTTP handlers and CLI for preview Postgres DB/role lifecycle, add extensive Postgres bootstrap/init tooling and verification scripts, reorganise environment variables and Docker Compose anchors, update Medusa BE DB/schema handling and publishable-key scripts, add Makefile/CI adjustments, and expand documentation.

Changes

Cohort / File(s) Summary
Root env & compose
\.env\.docker, docker-compose.yaml, docker-compose.prod.yaml
Reworked env layout into app runtime / external services / app DB identity; added many DC_* vars (DC_MEDUSA_APP_DB_, DC_ZANE_OPERATOR_, DC_MEILI_MASTER_KEY, DC_SUPERADMIN_, DC_MINIO_, DC_POSTGRES_*); added YAML env anchors and production volume resets; introduced zane-operator services and bootstrap wiring.
Makefile & CI
Makefile, .github/workflows/deploy-zaneops-preview.yml, zerops.yml
Added PROJECT_NAME, COMPOSE_DEV/COMPOSE_PROD, prod targets and Postgres bootstrap targets; new commented workflow for ZaneOps preview dispatch; updated Zerops init command to use Zerops-specific publishable-key script.
Zane Operator app
apps/zane-operator/*
package.json, project.json, tsconfig.json, src/*.ts, src/handlers/*, AGENTS.md, README.md, .env.example
New Bun-based operator: config loader, pg-utils, DB management library (preview DB lifecycle, role/grant sync, teardown), CLI (dev-user), bootstrap-role binary, HTTP handlers (ensure/teardown/health), auth and HTTP helpers, docs and examples.
Zane Operator docker
docker/development/zane-operator/Dockerfile
Multi-stage Bun build producing compiled binaries, non-root runtime image, HEALTHCHECK and ENTRYPOINT for operator container.
Postgres init & bootstrap scripts
docker/development/postgres/initdb/01-zane-role-bootstrap.sh, scripts/apply-postgres-role-bootstrap.sh, scripts/apply-zane-operator-role-bootstrap.sh, scripts/verify-postgres-grants.sh
New initdb script to create app/dev roles, migrate legacy objects into app schema, apply grants across DBs/schemas; helper scripts to apply and verify idempotence, plus operator bootstrap runner.
Medusa BE changes
apps/medusa-be/.env.template, apps/medusa-be/medusa-config.ts, apps/medusa-be/package.json, apps/medusa-be/src/scripts/*, apps/medusa-be/src/utils/db.ts, apps/medusa-be/README.md
Added MEDUSA_DATABASE_SCHEMA handling and precedence in config; DATABASE_URL search_path appended; new scripts to create initial superadmin and to create/emit publishable keys (including Zerops variant); updated env template and README with schema and bootstrap guidance.
DB management module & pg utils
apps/zane-operator/src/db.ts, apps/zane-operator/src/pg-utils.ts
Large new Postgres management library: typed APIs (ensurePreviewDatabase, createOrUpdateDevRole, teardownPreviewDatabase), advisory locking, grant/ownership operations, introspection, and robust validation utilities.
HTTP helpers & auth
apps/zane-operator/src/http.ts, apps/zane-operator/src/auth.ts
Added jsonResponse/jsonError/mapHandlerError utilities and enforceBearerToken with timing-safe comparison for protected endpoints.
Handlers & server
apps/zane-operator/src/handlers/*, apps/zane-operator/src/main.ts
Added ensure-preview-db, teardown-preview-db and health handlers and a Bun HTTP server wiring routes, auth enforcement, startup/shutdown and centralized error mapping.
Dockerfiles (frontend/backend)
docker/development/medusa-be/Dockerfile, docker/development/n1/Dockerfile
Optimised Corepack usage, pnpm store caching and deterministic offline installs; adjusted production build/runtime steps.
Docs & README updates
README.md, apps/zane-operator/AGENTS.md, apps/zane-operator/README.md, apps/zane-operator/.env.example
Expanded Run/Bootstrap guidance, added operator design/agent docs, env examples and local dev instructions.
Misc small edits
docker-compose.yaml anchors applied across services, Makefile wiring, zerops.yml
Supporting adjustments: medusa-db flags, healthchecks, new env propagation and small package.json script updates.

Sequence Diagram(s)

sequenceDiagram
    actor Client
    participant ZaneOp as Zane Operator
    participant Auth as Auth Helper
    participant Handler as Ensure Handler
    participant DBLib as Zane DB Lib
    participant Postgres as PostgreSQL

    Client->>ZaneOp: POST /v1/preview-db/ensure (Bearer)
    ZaneOp->>Auth: enforceBearerToken(req, TOKEN)
    Auth-->>ZaneOp: ok / error

    alt auth ok
        ZaneOp->>Handler: handleEnsurePreviewDb(req)
        Handler->>DBLib: ensurePreviewDatabase(prNumber, template, owner)
        DBLib->>Postgres: CHECK role & DB existence
        Postgres-->>DBLib: status
        alt missing
            DBLib->>Postgres: CREATE ROLE / CREATE DB (from template)
            Postgres-->>DBLib: created
        end
        DBLib->>Postgres: APPLY grants, set search_path, defaults
        Postgres-->>DBLib: done
        DBLib-->>Handler: {dbName, created, appUser, appPassword}
        Handler-->>Client: 200 {db_name, created, app_user, app_password}
    else auth error
        Auth-->>Client: 401/403
    end
Loading
sequenceDiagram
    participant Init as initdb script
    participant Postgres as PostgreSQL
    participant Medusa as Medusa BE

    Init->>Postgres: Validate POSTGRES_* env
    Init->>Postgres: CREATE/ALTER ROLE medusa_app, medusa_dev
    Postgres-->>Init: roles ensured
    Init->>Postgres: Create app DB/schema, migrate objects from public
    Postgres-->>Init: ownership & grants applied
    Init-->>Medusa: Role bootstrap complete
    Medusa->>Postgres: Connect as medusa_app
    Postgres-->>Medusa: Connection OK
Loading

Estimated Code Review Effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Poem

🗃️ Zane wakes and turns the key,
PRs bloom databases free,
Roles hum neat grants in tidy rows,
Medusa tends schemas where storefronts grow,
Bootstrap whispers idempotent pros.

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The pull request title clearly summarises the main changes: adding a new zane-operator service for preview database management and hardening the production Docker/Postgres build path.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat/zaneops_db_http

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 34

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In @.env.docker:
- Around line 72-79: The .env.docker contains runnable placeholder secrets
(e.g., DC_ZANE_OPERATOR_API_AUTH_TOKEN, DC_ZANE_OPERATOR_PGPASSWORD,
DC_ZANE_OPERATOR_DB_PREVIEW_APP_PASSWORD_SECRET) which can be accidentally
deployed; change these to fail-fast defaults by removing the placeholder values
and leaving them empty (e.g., DC_ZANE_OPERATOR_API_AUTH_TOKEN= and
DC_ZANE_OPERATOR_PGPASSWORD= and
DC_ZANE_OPERATOR_DB_PREVIEW_APP_PASSWORD_SECRET=) or omit the assignments
entirely so startup will error until real secrets are provided; keep non-secret
identifiers (DC_ZANE_OPERATOR_PGUSER, DC_ZANE_OPERATOR_PGDATABASE,
DC_ZANE_OPERATOR_DB_TEMPLATE_NAME, DC_ZANE_OPERATOR_DB_PREVIEW_PREFIX,
DC_ZANE_OPERATOR_DB_PREVIEW_APP_USER_PREFIX) intact.

In `@apps/medusa-be/medusa-config.ts`:
- Around line 31-34: The config uses the logical OR operator causing nullish
values like empty string to be incorrectly replaced; update the databaseSchema
assignment to use the nullish coalescing operator (replace the `||` chain with
`??`) so it matches the TypeScript guideline and other config entries—locate the
databaseSchema property in medusa-config.ts and change the expression
referencing process.env.MEDUSA_DATABASE_SCHEMA, process.env.DATABASE_SCHEMA, and
"public" to use `??` between them.

In `@apps/medusa-be/package.json`:
- Around line 38-39: The npm scripts setInitialPublishableKey and
setInitialPublishableKeyZerops currently run bash pipelines without pipefail so
downstream commands can succeed even if medusa exec or the extraction fails;
update both scripts to run their bash -c commands with strict failure handling
(enable pipefail and exit-on-error, e.g. set -euo pipefail) so any failure in
medusa exec or the grep/sed pipeline returns non-zero and prevents writing an
empty/invalid key to Zerops; ensure you apply this change to the commands
referenced by the package.json keys "setInitialPublishableKey" and
"setInitialPublishableKeyZerops".

In `@apps/medusa-be/src/scripts/create-initial-superadmin.ts`:
- Line 85: The current console.log call in create-initial-superadmin.ts
(console.log(`Superadmin is ready: ${email}`)) leaks PII; replace it with a
non-identifying success message or a masked email before logging (e.g., show
only domain or first/last char with asterisks) and log that masked value instead
of the raw email, keeping the update inside the same script/function where
console.log and the email variable are used.
- Around line 57-65: Replace the generic Error throws in
create-initial-superadmin.ts with MedusaError to follow Medusa error handling:
import MedusaError from "medusa-core-utils" and throw new
MedusaError(MedusaError.Types.INVALID_DATA, ...) when registration.error or
!registration.success and when !registration.authIdentity, preserving the
current messages (including interpolation of registration.error) so callers can
handle MedusaError types consistently; update both throw sites that reference
the registration variable accordingly.

In `@apps/medusa-be/src/utils/db.ts`:
- Around line 13-14: Replace the logical-OR fallback with a nullish-coalescing
fallback so empty-string DATABASE_URL values are preserved: in the expression
using process.env.DATABASE_URL and the PostgreSQL fallback string, change the
operator from || to ?? (i.e., use process.env.DATABASE_URL ?? "<fallback>") so
the fallback only applies when DATABASE_URL is null or undefined; update the
occurrence in the db initialization code that references
process.env.DATABASE_URL and the long
"postgresql://root:root@medusa-db:5432/medusa?..." literal.

In `@apps/zane-operator/package.json`:
- Around line 1-14: The package.json changes must be applied via pnpm CLI
instead of editing the file directly; locate the scripts block (keys like "dev",
"start", "bootstrap:role", "create:dev-user", "build", "typecheck") in the
zane-operator manifest and re-create each change using pnpm commands (for
example use pnpm pkg set scripts.dev="bun --watch src/main.ts" and similarly for
scripts.start, scripts.bootstrap:role, scripts.create:dev-user, scripts.build,
and scripts.typecheck) so the repository policy is respected and the manifest is
regenerated by pnpm rather than manual JSON edits.

In `@apps/zane-operator/project.json`:
- Around line 8-14: Update the "build" target in project.json to declare
cacheable outputs and to depend on the typecheck target: add an "outputs" array
describing the build artifacts (e.g., dist folder produced by bun build) and add
a "dependsOn" entry that references the project's "typecheck" target (e.g.,
"dependsOn": ["^typecheck" or "typecheck"] depending on desired scope). Modify
the existing "build" target (executor "nx:run-commands" with command "bun run
build") to include these keys so Nx can cache outputs and run typecheck before
build in CI.

In `@apps/zane-operator/README.md`:
- Around line 89-90: Update the README text to use the implemented environment
variable name DB_APP_SCHEMA instead of MEDUSA_APP_DB_SCHEMA; locate the
paragraph referencing MEDUSA_APP_DB_SCHEMA (around the Postgres bootstrap
migration description) and replace that token with DB_APP_SCHEMA so
documentation matches the operator implementation and avoids misconfiguration.
- Around line 323-421: The ordered list in README.md (the top-level steps titled
"Pause preview DB operations", "Export source database", "Restore into a staging
DB on target cluster", "Validate staging content", "Swap staging into
`template_medusa`", and "Re-enable preview operations and verify") uses repeated
"1." prefixes which triggers MD029; update these list prefixes to a proper
sequential ordered list (1., 2., 3., 4., 5., 6.) and ensure any nested lists
keep a consistent prefix style so markdownlint MD029 is satisfied.

In `@apps/zane-operator/src/bootstrap-role.ts`:
- Around line 1-10: Duplicate identifier utilities (IDENTIFIER_REGEX,
MAX_IDENTIFIER_LENGTH, assertSafeIdentifier, quoteIdentifier, quoteLiteral,
roleExists, databaseExists) exist in bootstrap-role.ts and db.ts; extract them
into a single shared module (e.g., src/pg-utils.ts), move the constants and
functions (IDENTIFIER_REGEX, MAX_IDENTIFIER_LENGTH, assertSafeIdentifier,
quoteIdentifier, quoteLiteral, roleExists, databaseExists) into that file,
export them, and then replace the local definitions in both bootstrap-role.ts
and db.ts with imports from the new module so both files use the same canonical
implementations.
- Around line 118-142: The target role password currently falls back to the
superuser PGPASSWORD in loadBootstrapConfig (targetPassword =
env.BOOTSTRAP_TARGET_PASSWORD?.trim() || readRequiredEnv(env, "PGPASSWORD")),
causing the operator role to inherit admin credentials; change this so
BOOTSTRAP_TARGET_PASSWORD is required (call readRequiredEnv(env,
"BOOTSTRAP_TARGET_PASSWORD") when missing) or instead fall back to a dedicated
non-admin variable like DC_ZANE_OPERATOR_PGPASSWORD
(env.DC_ZANE_OPERATOR_PGPASSWORD?.trim() || readRequiredEnv(env,
"BOOTSTRAP_TARGET_PASSWORD") removed), and update references to
BOOTSTRAP_TARGET_PASSWORD/PGPASSWORD accordingly to ensure the operator never
silently uses the superuser password.
- Around line 168-203: The variable name created in applyBootstrap is misleading
because it’s set from !(await roleExists(...)) before CREATE ROLE runs; rename
it to needsCreation or isNewRole (e.g., const needsCreation = !(await
roleExists(sql, config.targetRole))) and update its usages (the if check that
calls sql.unsafe(`CREATE ROLE ${quotedTargetRole} LOGIN;`)) so it clearly means
“role did not exist and needs creation” rather than “was created”; ensure all
references in applyBootstrap reflect the new name.

In `@apps/zane-operator/src/cli.ts`:
- Around line 16-17: The CLI currently documents and accepts plaintext passwords
via argv for the create-dev-user command (see the usage string around
"create-dev-user" and the flag definitions at the blocks around lines 42-44 and
65-67); add a new non-argv option --password-env <VAR> and update the usage
strings to prefer it, then change the parsing/handling logic so that if
--password-env is provided the code reads the password from process.env[VAR]; if
not provided, fall back to a secure prompt (not argv) or fail with a clear
message asking to use --password-env; update help text and all occurrences of
the plaintext --password usage to recommend --password-env instead.

In `@apps/zane-operator/src/config.ts`:
- Around line 112-118: The code currently falls back to API_AUTH_TOKEN when
DB_PREVIEW_APP_PASSWORD_SECRET is not set (previewAppPasswordSecret =
explicitPreviewSecret || apiAuthToken), coupling two secrets; change this so
previewAppPasswordSecret is NOT derived from API_AUTH_TOKEN: keep the production
check (isProduction && !explicitPreviewSecret) that throws, and for
non-production replace the fallback to apiAuthToken with a generation of a
dedicated, high-entropy secret (e.g., use crypto.randomBytes or an equivalent
secure RNG) or require an explicit preview secret—remove any reference to
apiAuthToken when computing previewAppPasswordSecret and ensure the new behavior
is used by the previewAppPasswordSecret variable and related logic.

In `@apps/zane-operator/src/db.ts`:
- Around line 875-894: The loop under params.grantConnectToAllDatabases
sequentially iterates listNonTemplateDatabases and opens a connection per
database via withDatabaseClientByUrl while calling grantReadWriteOnSchema and
grantReadWriteDefaultPrivilegesOnSchema, which can be slow; add a log before the
loop that reports the total number of databases (from listNonTemplateDatabases)
so operators can see scope, and refactor the per-database work to run with
bounded concurrency (e.g., use a promise concurrency limiter) so you open
multiple withDatabaseClientByUrl calls in parallel while preserving correct
increments to connectGrantsApplied, schemaGrantsApplied,
defaultPrivilegeOwnersApplied and defaultPrivilegeOwnersSkipped and ensuring
errors are propagated/handled consistently.
- Around line 335-498: Summary: transferSchemaOwnershipToRole and
transferOwnedObjectsInSchemaToRole duplicate ~70 lines of PL/pgSQL; consolidate
into one helper. Create a new function transferObjectsOwnership(databaseSql,
schemaName, targetRole, sourceRole?) that emits a single DO block and
conditionally includes the owner-filter and schema-owner-check fragments when
sourceRole is provided (i.e. add AND pg_get_userbyid(...)=... on
rel/routine/type queries and the IF EXISTS(schema owner) ALTER SCHEMA ... block
only when sourceRole is set); then replace transferSchemaOwnershipToRole and
transferOwnedObjectsInSchemaToRole with thin wrappers that call
transferObjectsOwnership with sourceRole omitted or provided respectively,
reusing existing quoteLiteral/quoteIdentifier helpers and preserving the same
relkind/routine/type handling.
- Around line 621-624: Remove the redundant REVOKE statement that follows the
ALL PRIVILEGES revoke: the call to sql.unsafe that executes `REVOKE CREATE,
TEMPORARY ON DATABASE ${quoteIdentifier(dbName)} FROM
${quoteIdentifier(appRoleName)};` should be deleted because `REVOKE ALL
PRIVILEGES ON DATABASE ${quoteIdentifier(dbName)} FROM
${quoteIdentifier(appRoleName)};` already covers CREATE and TEMPORARY; locate
the two adjacent sql.unsafe calls around the variables dbName, appRoleName and
remove the second one.
- Around line 930-953: The returned value terminatedConnections is computed by
countActiveDatabaseConnections before calling dropDatabase, so it may not
reflect the actual sessions forcefully terminated by dropDatabase; update the
result and semantics by renaming the field (e.g., activeConnectionsAtDrop or
estimatedTerminatedConnections) and its usage wherever consumed, and/or change
the logic to compute the true termination count inside dropDatabase (or have
dropDatabase return the number of terminated sessions) so the returned object
from the withAdvisoryLock block (the properties dbName, deleted,
terminatedConnections/appUser/roleDeleted/devGrantsCleaned/noop/noopReason)
accurately matches the new name or actual termination count; ensure references
to terminatedConnections in consumers are updated to the new field or adjusted
to expect an estimate.
- Around line 31-33: quoteCatalogIdentifier currently only escapes embedded
quotes and omits the safety check performed by quoteIdentifier, making it unsafe
if ever used with user-derived input; update quoteCatalogIdentifier to call
assertSafeIdentifier(identifier) before quoting (same behavior as
quoteIdentifier) OR rename the function to signal “catalog-only” usage and add a
comment, and then update callers (e.g., any places that previously used
quoteCatalogIdentifier) to use the validated quoteIdentifier if they might pass
user input; reference the quoteCatalogIdentifier and assertSafeIdentifier
symbols when making the change.
- Around line 231-233: The GRANT statement using the MAINTAIN privilege can fail
on PostgreSQL <17; update the startup/database grant logic around
databaseSql.unsafe so it either queries server_version_num (e.g., SELECT
current_setting('server_version_num')) and conditionally omits "MAINTAIN" when
version < 170000, or wrap the unsafe call in a try/catch to retry the same GRANT
without MAINTAIN on error; target symbols to change: the GRANT invocation using
databaseSql.unsafe and the variables quotedSchemaName/quotedRoleName that build
the statement.

In `@apps/zane-operator/src/main.ts`:
- Around line 92-103: The shutdown code in handleShutdown currently calls
sql.close() without guarding against rejection, which can abort the controlled
shutdown; wrap the shutdown sequence in a try/catch/finally: call
server.stop(true) and await sql.close({ timeout: 5 }) inside try, catch any
error from sql.close() (or server.stop) and emit an explicit shutdown error
event (e.g., console.error(JSON.stringify({ event: "server.shutdown.error",
signal, error: err.message || String(err) }))) and ensure shuttingDown remains
set, then call process.exit(1) or process.exit(0) as appropriate in finally to
guarantee the process always terminates. Ensure you reference handleShutdown,
sql.close, server.stop, shuttingDown and process.exit when making the changes.

In `@apps/zane-operator/tsconfig.json`:
- Around line 5-11: The tsconfig currently sets "outDir": "dist" while "noEmit":
true; remove the redundant "outDir" property from the tsconfig.json so the
config is not misleading—locate the "outDir" key near the "noEmit" and delete
that line (keep "noEmit": true and other compiler options intact).

In `@docker-compose.yaml`:
- Around line 25-32: Add a short explanatory comment above the DATABASE_URL
environment variable that shows the decoded form of the URL-encoded options
(e.g., options=-csearch_path=medusa,pg_catalog) and warns maintainers that %3D
is "=" and %2C is "," so manual edits must preserve URL-encoding; update the
block around the DATABASE_URL/LEGACY_DATABASE_URL entries (look for the
DATABASE_URL line and MEDUSA_DATABASE_SCHEMA/DATABASE_SCHEMA settings) to
include that comment for clarity.
- Around line 52-74: Add a production-only check in the zane-operator config
loader (where readRequiredEnv() is used in config.ts) to reject the literal
placeholder "replace-me" for the sensitive env keys PGPASSWORD,
DB_PREVIEW_APP_PASSWORD_SECRET and API_AUTH_TOKEN: after reading each required
value (or centrally after building the config), if process.env.NODE_ENV ===
"production" and any of those values === "replace-me", throw an Error with a
clear message identifying which variable is invalid and fail fast; update
readRequiredEnv() or the config initialization path to perform this validation
so the app never starts with placeholder secrets.

In `@docker/development/medusa-be/Dockerfile`:
- Line 20: The Dockerfile uses a shell "RUN cd /tmp/corepack && corepack install
&& rm -rf /tmp/corepack" which triggers DL3003; replace the inline cd with a
WORKDIR directive and update the RUN to operate directly in that directory: add
WORKDIR /tmp/corepack before the install step, change the RUN to run "corepack
install && rm -rf /tmp/corepack" (or remove the directory using an absolute
path) and then, if needed, reset WORKDIR to the prior location (e.g., WORKDIR /)
so subsequent commands run in the expected context; target the RUN line that
invokes corepack and the Dockerfile WORKDIR directives when making the change.
- Line 74: Update the Docker CMD so the final long-lived process is executed
with exec semantics: modify the existing CMD that runs "npm run migrateProd &&
npm run entrypoint && npm run start" to use exec for the final command so the
"npm run start" process replaces the shell (PID 1) and receives SIGTERM
directly; keep the existing migration and entrypoint chaining ("npm run
migrateProd" and "npm run entrypoint") but ensure the final invocation of "npm
run start" is prefixed with exec.

In `@docker/development/n1/Dockerfile`:
- Line 20: Replace the shell "cd" usage in the dev-stage Dockerfile with a
WORKDIR directive: set WORKDIR to /tmp/corepack before running the corepack
install steps (so change the sequence where RUN cd /tmp/corepack && corepack
install && rm -rf /tmp/corepack is used), then run corepack install and the
cleanup in a subsequent RUN command; this uses WORKDIR to change directory for
subsequent instructions and keeps layers cleaner.

In `@Makefile`:
- Around line 30-35: The Makefile's prod and prod-no-cache targets are not
declared .PHONY, so Make may treat them as files; add a .PHONY declaration
including prod and prod-no-cache (and related non-file targets such as prod-run,
dev, down, down-with-volumes, postgres-role-bootstrap,
postgres-role-bootstrap-verify, postgres-zane-operator-bootstrap,
postgres-zane-operator-bootstrap-verify, postgres-grants-verify) near the top of
the Makefile or before the target definitions to ensure Make always runs these
targets instead of treating them as files.
- Line 38: Replace hardcoded image names in the docker rmi call with the
PROJECT_NAME variable so image names follow the pattern
"$(PROJECT_NAME)-medusa-be" and "$(PROJECT_NAME)-n1"; update the line containing
the docker rmi invocation to construct image names using PROJECT_NAME to ensure
cleanup works if the project name changes.
- Around line 63-70: Add an inline comment above the n1 generation step in the
Makefile explaining that this rule intentionally uses $(COMPOSE_DEV) (not
$(COMPOSE_PROD)) because the dev compose mounts the host source tree
(.:/var/www) which is required for running pnpm --filter n1 run
generate:categories; explicitly state that this creates a runtime dependency on
the dev compose configuration/host mount for the production build and note any
intended mitigation or why this tradeoff is acceptable (references: COMPOSE_DEV,
COMPOSE_PROD, n1, generate:categories).

In `@README.md`:
- Around line 33-45: The fenced shell code blocks containing the make commands
(the blocks with "make postgres-role-bootstrap / make
postgres-zane-operator-bootstrap", the idempotency block with "make
postgres-role-bootstrap-verify / make postgres-zane-operator-bootstrap-verify",
and the grants check block "make postgres-grants-verify") are missing
surrounding blank lines and trigger MD031; update README.md by inserting a blank
line before and after each of these triple-backtick fenced code blocks so each
code fence is separated from surrounding list text, preserving the existing
indentation and content.

In `@scripts/apply-postgres-role-bootstrap.sh`:
- Line 10: The script currently runs "docker compose exec -T medusa-db ..."
without verifying the medusa-db container is running; add a pre-flight guard
that checks the container is up (e.g. use "docker compose ps -q medusa-db" and
test for a non-empty result) and if absent print a clear error and exit non-zero
before attempting the exec; update the script around the existing "docker
compose exec -T medusa-db sh
/docker-entrypoint-initdb.d/01-zane-role-bootstrap.sh" line to perform this
check and early-return with a helpful message.

In `@scripts/apply-zane-operator-role-bootstrap.sh`:
- Around line 10-14: The script currently passes --build to both docker compose
run invocations (the initial run and the idempotency re-run when
--verify-idempotent is used); change the script so --build is only applied to
the first invocation (docker compose run --rm [--build] zane-operator-bootstrap)
or make --build opt-in by adding a new flag (e.g., parse a --build/--rebuild CLI
option) and forward it only to the first docker compose run, keeping the second
re-run that sets BOOTSTRAP_VERIFY_IDEMPOTENT=1 free of --build to avoid
unnecessary image rebuilds.

ℹ️ Review info

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 08775d9 and 18f9a18.

📒 Files selected for processing (37)
  • .env.docker
  • .github/workflows/deploy-zaneops-preview.yml
  • Makefile
  • README.md
  • apps/medusa-be/.env.template
  • apps/medusa-be/README.md
  • apps/medusa-be/medusa-config.ts
  • apps/medusa-be/package.json
  • apps/medusa-be/src/scripts/create-initial-superadmin.ts
  • apps/medusa-be/src/scripts/output-publishable-key.ts
  • apps/medusa-be/src/utils/db.ts
  • apps/zane-operator/.env.example
  • apps/zane-operator/AGENTS.md
  • apps/zane-operator/README.md
  • apps/zane-operator/package.json
  • apps/zane-operator/project.json
  • apps/zane-operator/src/auth.ts
  • apps/zane-operator/src/bootstrap-role.ts
  • apps/zane-operator/src/cli.ts
  • apps/zane-operator/src/config.ts
  • apps/zane-operator/src/db.ts
  • apps/zane-operator/src/handlers/ensure-preview-db.ts
  • apps/zane-operator/src/handlers/health.ts
  • apps/zane-operator/src/handlers/teardown-preview-db.ts
  • apps/zane-operator/src/http.ts
  • apps/zane-operator/src/main.ts
  • apps/zane-operator/tsconfig.json
  • docker-compose.prod.yaml
  • docker-compose.yaml
  • docker/development/medusa-be/Dockerfile
  • docker/development/n1/Dockerfile
  • docker/development/postgres/initdb/01-zane-role-bootstrap.sh
  • docker/development/zane-operator/Dockerfile
  • scripts/apply-postgres-role-bootstrap.sh
  • scripts/apply-zane-operator-role-bootstrap.sh
  • scripts/verify-postgres-grants.sh
  • zerops.yml

Comment thread .env.docker Outdated
Comment thread apps/medusa-be/medusa-config.ts
Comment thread apps/medusa-be/package.json Outdated
Comment thread apps/medusa-be/src/scripts/create-initial-superadmin.ts
Comment thread apps/medusa-be/src/scripts/create-initial-superadmin.ts Outdated
Comment thread Makefile Outdated
Comment thread Makefile Outdated
Comment thread README.md
Comment thread scripts/apply-postgres-role-bootstrap.sh
Comment thread scripts/apply-zane-operator-role-bootstrap.sh Outdated

@redeyecz redeyecz left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@coderabbitai

Reviewed all findings against current code and addressed the valid ones only.

Implemented:

  • Fail-fast secret handling in env/compose, removed unsafe defaults.
  • || -> ?? fixes where appropriate.
  • Script hardening with bash -euo pipefail.
  • PII-safe superadmin logging and MedusaError alignment.
  • Secure CLI password flow via --password-env (no plaintext argv).
  • Nx build target improvements (dependsOn + outputs).
  • Bootstrap/operator config drift cleanup (strict role/admin credential split, reduced env drift).
  • DB_PROTECTED_NAMES cleanup (system DB defaults only) and code-side protection of active PGDATABASE + template DB.
  • zane-operator DB refactors: shared PG utils, ownership transfer helper consolidation, redundant revoke removal, bounded concurrency for broad grants, teardown metric
    rename for correct semantics.
  • Dockerfile and docs/Makefile lint/maintainability fixes.

Not applied (by validation/decision):

  • PG <17 MAINTAIN fallback: not applicable (project baseline is PostgreSQL 18).
  • “package.json must be changed via pnpm CLI”: process-only, no functional defect in current file state.
  • replace-me production guard: intentionally removed (required envs already fail fast, and this check was considered too narrow).

Comment thread .env.docker Outdated
Comment thread docker-compose.yaml
Comment thread apps/zane-operator/src/config.ts Outdated
Comment thread apps/zane-operator/src/cli.ts Outdated
Comment thread apps/medusa-be/package.json Outdated
Comment thread README.md
Comment thread apps/zane-operator/src/bootstrap-role.ts
Comment thread apps/zane-operator/package.json
Comment thread apps/zane-operator/src/bootstrap-role.ts
Comment thread apps/zane-operator/src/bootstrap-role.ts

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 9

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
apps/medusa-be/src/utils/db.ts (1)

1-16: ⚠️ Potential issue | 🔴 Critical

refactor(db): replace drizzle-orm/neon-http adapter with standard PostgreSQL adapter.

The neon-http adapter communicates exclusively via Neon's HTTP proxy and is incompatible with standard PostgreSQL TCP connections. All deployment environments (docker-compose and zerops) configure DATABASE_URL to standard Postgres endpoints (e.g. postgresql://root:root@medusa-db:5432/medusa), not Neon proxies. This mismatch will cause query execution to fail at runtime.

Use drizzle-orm/node-postgres or drizzle-orm/postgres-js instead, which support standard Postgres connections.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/medusa-be/src/utils/db.ts` around lines 1 - 16, Replace the Neon HTTP
adapter with a standard Postgres adapter: stop importing drizzle from
"drizzle-orm/neon-http" and instead use the node-postgres (or postgres-js)
adapter; create a pg connection pool/client from 'pg' and pass that client to
drizzle along with the existing schema, keeping the same db identifier and using
process.env.DATABASE_URL as the connection string fallback — update imports and
the instantiation around drizzle, schema, and DATABASE_URL accordingly so the
app uses a TCP Postgres client (e.g., Pool from 'pg') rather than the Neon HTTP
adapter.
♻️ Duplicate comments (15)
apps/zane-operator/tsconfig.json (1)

1-13: chore: previous outDir redundancy has been addressed — config looks clean.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/zane-operator/tsconfig.json` around lines 1 - 13, tsconfig.json looks
clean after removing the redundant "outDir"; no change required—just verify that
the "compilerOptions" entries (rootDir, module, moduleResolution, types,
composite, incremental, noEmit) and "include" match project expectations and
leave as-is in apps/zane-operator/tsconfig.json.
apps/zane-operator/src/cli.ts (1)

1-155: feat(cli): previous plaintext --password security concern fully resolved — --password-env enforcement looks solid.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/zane-operator/src/cli.ts` around lines 1 - 155, The reviewer approved
the changes and flagged no functional issues; no code changes are
necessary—leave parseCreateDevUserArgs (which enforces --password-env and reads
the env var), runCreateDevUser (which enforces production grant rules), and
main/error handling as-is; if you see a duplicated review comment, remove the
duplicate metadata only (no code edits).
apps/zane-operator/README.md (1)

326-424: ⚠️ Potential issue | 🟡 Minor

docs: sequential ordered-list prefixes still violate MD029 (style 1/1/1).

The steps at lines 326 → 424 use 1., 2., 3.… which is style 1/2/3. markdownlint expects 1/1/1. Please normalise all prefixes to 1. (Markdown renderers still display them as sequential numbers).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/zane-operator/README.md` around lines 326 - 424, The ordered lists in
the README steps ("1. Pause preview DB operations", "2. Export source database",
"3. Restore into a staging DB on target cluster", etc.) use increasing numeric
prefixes which triggers MD029; change every ordered-list prefix in that block so
each item uses "1." (e.g., replace "2.", "3.", "4.", "5.", "6." with "1.") while
keeping the same item text and code fences intact to satisfy markdownlint style.
apps/medusa-be/src/scripts/create-initial-superadmin.ts (2)

70-82: fix: MedusaError with INVALID_DATA type correctly replaces generic Error — previous feedback resolved.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/medusa-be/src/scripts/create-initial-superadmin.ts` around lines 70 -
82, The code should not throw generic Error types here; replace any remaining
generic Error throws with MedusaError using MedusaError.Types.INVALID_DATA in
create-initial-superadmin.ts: specifically update the two throw sites around the
registration result checks (the branch that checks registration.error ||
!registration.success and the branch that checks !registration.authIdentity) to
throw MedusaError.Types.INVALID_DATA and include registration.error details when
available in the log/message.

102-102: fix: PII-safe masked email in log output — previous feedback resolved.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/medusa-be/src/scripts/create-initial-superadmin.ts` at line 102, The
current console.log call should be replaced with the application's structured
logger while preserving the PII-safe masked email; change the
console.log(`Superadmin is ready: ${maskEmailForLog(email)}`) to use the
project's logger (e.g., logger.info or processLogger.info) and keep
maskEmailForLog(email) so the email remains masked; update the call site where
console.log is used (related to maskEmailForLog and the email variable) to emit
an info-level log via the app logger.
apps/medusa-be/src/utils/db.ts (1)

13-14: fix(deps): ?? fallback correctly resolves the previous review concern.

Lines 13–14 now use nullish coalescing, ensuring the fallback URL is applied only when DATABASE_URL is null or undefined. The explicit search_path in the default string is also a welcome hardening.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/medusa-be/src/utils/db.ts` around lines 13 - 14, The fallback for
DATABASE_URL should use the nullish coalescing operator and the explicit default
connection string as you changed; ensure process.env.DATABASE_URL is compared
with ?? (not ||) and keep the hardcoded fallback string containing the encoded
search_path, e.g., process.env.DATABASE_URL ??
"postgresql://root:root@medusa-db:5432/medusa?sslmode=disable&options=-csearch_path%3Dmedusa%2Cpg_catalog",
so the default only applies when DATABASE_URL is null or undefined.
docker/development/medusa-be/Dockerfile (2)

19-22: refactor(docker): WORKDIR pattern correctly replaces RUN cd — previous feedback addressed.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docker/development/medusa-be/Dockerfile` around lines 19 - 22, Refactor
Dockerfile to use WORKDIR instead of RUN cd: ensure WORKDIR /tmp/corepack
appears before COPY package.json so the file lands at
/tmp/corepack/package.json, keep the RUN corepack install && rm -rf
/tmp/corepack to perform install and clean up, and finally reset the context
with WORKDIR /var/www; verify the unique directives WORKDIR, COPY package.json
/tmp/corepack/package.json, and RUN corepack install && rm -rf /tmp/corepack are
present and ordered as described.

76-76: refactor(docker): exec before the final process correctly addresses the previous PID 1 signal-handling feedback.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docker/development/medusa-be/Dockerfile` at line 76, The CMD currently uses a
shell to run migration and entrypoint but you must ensure the final process
replaces PID 1 so signals are handled; update the Dockerfile CMD (the CMD ["sh",
"-c", "..."] line) to run migrations and entrypoint normally and use exec only
for the final start command, e.g. wrap the chain in a single shell string: "sh
-c 'npm run migrateProd && npm run entrypoint && exec npm run start'" so exec
replaces the shell with the Node process.
apps/zane-operator/project.json (1)

8-16: chore: dependsOn and outputs additions resolve the previous feedback — well done.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/zane-operator/project.json` around lines 8 - 16, The review shows the
added "dependsOn" and "outputs" in the "build" target are correct but a
duplicate approval comment was left; remove the duplicate review/approval
comment on this PR and keep a single approval, and if needed add a short
resolved note; locate the "build" target entry (keys "dependsOn" and "outputs")
in the project.json to confirm no further changes are required before clearing
the duplicate comment.
apps/medusa-be/medusa-config.ts (1)

31-34: fix(config): nullish coalescing for databaseSchema — resolved!

The ?? chain is now consistent with the rest of projectConfig and complies with the TypeScript coding guideline ("use ?? for null/undefined checks, never ||"). Past review concern is fully addressed. ✓

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/medusa-be/medusa-config.ts` around lines 31 - 34, The databaseSchema
assignment in projectConfig should use nullish coalescing (??) instead of
logical OR so it only falls back on null/undefined; update the databaseSchema
property in medusa-config.ts (look for projectConfig and the databaseSchema
symbol) to use process.env.MEDUSA_DATABASE_SCHEMA ?? process.env.DATABASE_SCHEMA
?? "public" and check for any other occurrences that still use || and replace
them with ?? where appropriate.
scripts/apply-zane-operator-role-bootstrap.sh (1)

10-15: feat(scripts): zane-operator bootstrap runner — looks good!

The --build flag is correctly scoped to the first invocation only (line 10); the idempotency re-run at line 14 omits --build, addressing the past review concern. Using set -eu in a POSIX sh script is appropriate (pipefail is bash-specific and correctly excluded here).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@scripts/apply-zane-operator-role-bootstrap.sh` around lines 10 - 15, The
bootstrap script already scopes --build to the first docker compose run and
omits it for the idempotency re-run, and it correctly uses set -eu without
bash-only pipefail; no code changes required—keep the current commands in
scripts/apply-zane-operator-role-bootstrap.sh (the docker compose run invocation
and the conditional that checks "--verify-idempotent") and proceed to merge
as-is.
apps/zane-operator/src/main.ts (1)

92-118: fix(main): deterministic shutdown with try/catch/finally — resolved!

The shutdown handler now correctly wraps server.stop() and sql.close() in a try/catch/finally block, guaranteeing process.exit is always called with the appropriate exit code. Past review concern is fully addressed. ✓

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/zane-operator/src/main.ts` around lines 92 - 118, The shutdown handler
correctly uses try/catch/finally but doesn't await server.stop, which can make
shutdown non-deterministic; update handleShutdown so you await server.stop(true)
(or otherwise handle its returned Promise) before awaiting sql.close, keep the
try/catch/finally structure, and retain the shuttingDown guard and
process.exit(exitCode) in finally so exit happens after both shutdown operations
complete and any errors are caught.
README.md (1)

27-88: docs(readme): Bootstrap and cloud predeploy documentation — looks good!

The fenced code blocks now have the required surrounding blank lines (MD031 resolved ✓). The new bootstrap guidance is clear, structured, and consistent with the companion scripts (apply-postgres-role-bootstrap.sh, apply-zane-operator-role-bootstrap.sh) introduced in this PR.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@README.md` around lines 27 - 88, Summary: The review approves the README
changes but includes a duplicate reviewer tag; no code edits required. Action:
leave the README.md content as-is (the fenced code blocks and sections like
"Cloud predeploy hook (idempotent)" and the bootstrap/make targets are correct)
and remove the stray duplicate review marker from the PR metadata/comments (the
"[duplicate_comment]" tag) so there’s a single approval comment remaining; no
changes to apply-postgres-role-bootstrap.sh or
apply-zane-operator-role-bootstrap.sh are needed.
docker/development/n1/Dockerfile (1)

19-22: feat(docker): Corepack bootstrap via WORKDIR — looks good!

Past concern (DL3003/DS-0013: RUN cd) is now resolved. The temporary /tmp/corepack directory is cleanly managed: WORKDIR /tmp/corepackcorepack install && rm -rf /tmp/corepackWORKDIR /var/www is a safe and correct sequence; Docker commits the layer after the RUN completes, so the dangling WORKDIR is never visible to subsequent instructions.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docker/development/n1/Dockerfile` around lines 19 - 22, The Dockerfile
sequence using WORKDIR /tmp/corepack, COPY package.json, RUN corepack install &&
rm -rf /tmp/corepack, then WORKDIR /var/www is correct and requires no code
changes—leave the WORKDIR/RUN sequence as-is (ensure the RUN retains the rm -rf
/tmp/corepack cleanup and the two WORKDIR instructions remain).
apps/medusa-be/package.json (1)

39-39: ⚠️ Potential issue | 🟠 Major

fix: guard against empty token in setInitialPublishableKeyZerops

The script pipes setInitialPublishableKey stdout directly to zsc setSecretEnv. If the grep/sed extraction produces empty output (e.g., no <PK_TOKEN> in the medusa exec output), an empty string is stored as the secret, silently breaking the storefront. The previous review proposed an explicit non-empty check — that guard is absent in the current implementation.

🛠️ Proposed fix
-    "setInitialPublishableKeyZerops": "bash -euo pipefail -c \"npm run -s setInitialPublishableKey | tr -d '\\n\\r' | zsc setSecretEnv CHANNEL_PUBLISHABLE_KEY\""
+    "setInitialPublishableKeyZerops": "bash -euo pipefail -c \"TOKEN=\\$(npm run -s setInitialPublishableKey | tr -d '\\n\\r'); [ -n \\\"\\$TOKEN\\\" ] || { echo 'Publishable key token is empty' >&2; exit 1; }; printf '%s' \\\"\\$TOKEN\\\" | zsc setSecretEnv CHANNEL_PUBLISHABLE_KEY\""
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/medusa-be/package.json` at line 39, The npm script
setInitialPublishableKeyZerops currently pipes the raw output of
setInitialPublishableKey into zsc setSecretEnv and can write an empty secret;
change the script to capture the output of npm run -s setInitialPublishableKey
into a shell variable (e.g., TOKEN), trim/newline-strip it, check that TOKEN is
non-empty, and only then pass it to zsc setSecretEnv (otherwise print an error
and exit non‑zero); reference the npm script name setInitialPublishableKeyZerops
and the invoked command setInitialPublishableKey and zsc setSecretEnv so the
guard is added around that pipeline.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@apps/medusa-be/package.json`:
- Line 18: The package.json "entrypoint" currently runs "npm run
createInitialSuperadmin && npm run -s setInitialPublishableKey > /dev/null"
which intentionally discards stdout from setInitialPublishableKey; update the
entrypoint string to include a short inline comment explaining that
setInitialPublishableKey is executed for side‑effects only (verification of
success) and that the actual token is stored by the separate
"setInitialPublishableKeyZerops" flow, so the redirection to /dev/null is
deliberate and not a bug; reference the "entrypoint" value and the script names
"setInitialPublishableKey" and "setInitialPublishableKeyZerops" when adding the
comment.
- Line 30: The NPM script "createInitialSuperadmin" uses bash -c without strict
failure flags so the if/fi can swallow errors; update the command invoked by
createInitialSuperadmin to run the subshell with strict mode (set -euo pipefail)
before the if statement so any non-zero exit from medusa exec (either the .ts or
.js path) causes the script to fail; keep the existing if [ -f ... ]; then
medusa exec ...; else medusa exec ...; fi logic and ensure set -euo pipefail is
the first statement in that bash -c invocation so failures propagate to the
entrypoint's && chain.

In `@apps/zane-operator/project.json`:
- Around line 24-30: The "dev" target in project.json uses the "nx:run-commands"
executor to run a persistent server ("command": "bun run dev") and should
disable Nx caching to avoid skipped executions; update the "dev" target
configuration (the "dev" entry with executor "nx:run-commands" and options
containing "command": "bun run dev") to include "cache": false at the same level
as "options" so Nx will not cache the result of running the dev server.

In `@apps/zane-operator/README.md`:
- Around line 59-71: The README lists DB_PREVIEW_APP_PASSWORD_SECRET under the
"Optional env vars" header but also marks it "(required)"; update the doc so
it's consistent by moving DB_PREVIEW_APP_PASSWORD_SECRET out of the "Optional
env vars" block and into the "Required env vars" section (or alternatively
remove the "(required)" qualifier next to DB_PREVIEW_APP_PASSWORD_SECRET) and
ensure the "Required env vars" section includes the exact symbol
DB_PREVIEW_APP_PASSWORD_SECRET so operators can find it.

In `@apps/zane-operator/src/bootstrap-role.ts`:
- Around line 100-101: Add a guard immediately after reading targetRole (the
result of readRequiredEnv(env, "PGUSER")) to ensure it does not equal
BOOTSTRAP_ADMIN_PGUSER; if it does, throw an error or exit with a clear message
(and avoid continuing to use targetPassword) so the admin account cannot be
de-privileged. Locate the variables targetRole and BOOTSTRAP_ADMIN_PGUSER in
bootstrap-role.ts and implement the check (e.g., if (targetRole ===
BOOTSTRAP_ADMIN_PGUSER) throw new Error(...)) before any code that mutates roles
or uses targetPassword.

In `@apps/zane-operator/src/cli.ts`:
- Around line 74-79: The code trims the env password which can unintentionally
remove legitimate leading/trailing spaces; update the logic to read the raw
value from process.env using passwordEnvVar (assign to password without
.trim()), then check emptiness by ensuring the value is not undefined/null and
not an empty string (e.g., password === undefined || password === ''), and keep
throwing BadRequestError when truly missing; optionally add a warning if
password has leading/trailing whitespace by comparing password to
password.trim() but do not modify the value used for authentication.

In `@apps/zane-operator/src/db.ts`:
- Around line 591-593: Before running the CREATE DATABASE ... OWNER ...
statement, add an explicit validation that the requested owner role exists and
return a BadRequestError if it does not; locate the code using
normalizeIdentifier(params.owner, "owner") (variable owner) and
buildPreviewAppRoleName(...) (appUser) and, prior to the SQL that creates the
database (and likewise in the similar block around the code referenced at
603-610), query the catalog for the role (e.g., check pg_roles for the rolname)
for the owner value and throw a clear BadRequestError when no matching role is
found instead of letting the lower-level SQL error surface.

In `@docker-compose.yaml`:
- Around line 230-234: The healthcheck still targets a hard-coded database name
which will fail when POSTGRES_DB (set via DC_MEDUSA_APP_DB_NAME) is overridden;
update the healthcheck command for the Postgres service to reference the
configured DB name (use the same variable substitution as POSTGRES_DB, e.g.
${DC_MEDUSA_APP_DB_NAME:-medusa} or reference $POSTGRES_DB) and the existing
POSTGRES_USER/POSTGRES_PASSWORD env vars so the psql invocation (or pg_isready
call) checks the actual configured database instead of "medusa", ensuring the
service reports healthy when a custom DC_MEDUSA_APP_DB_NAME is used.

In `@docker/development/medusa-be/Dockerfile`:
- Line 24: Remove the stale commented-out CMD line that currently reads as a
commented Dockerfile command; delete the entire commented line (the line
starting with `#CMD` ["sh", "-c", "pnpm --filter medusa-be migrate && pnpm
--filter medusa-be entrypoint && pnpm --filter medusa-be dev"]) to clean up
noise and ensure there are no leftover commented Docker CMDs in the Dockerfile.

---

Outside diff comments:
In `@apps/medusa-be/src/utils/db.ts`:
- Around line 1-16: Replace the Neon HTTP adapter with a standard Postgres
adapter: stop importing drizzle from "drizzle-orm/neon-http" and instead use the
node-postgres (or postgres-js) adapter; create a pg connection pool/client from
'pg' and pass that client to drizzle along with the existing schema, keeping the
same db identifier and using process.env.DATABASE_URL as the connection string
fallback — update imports and the instantiation around drizzle, schema, and
DATABASE_URL accordingly so the app uses a TCP Postgres client (e.g., Pool from
'pg') rather than the Neon HTTP adapter.

---

Duplicate comments:
In `@apps/medusa-be/medusa-config.ts`:
- Around line 31-34: The databaseSchema assignment in projectConfig should use
nullish coalescing (??) instead of logical OR so it only falls back on
null/undefined; update the databaseSchema property in medusa-config.ts (look for
projectConfig and the databaseSchema symbol) to use
process.env.MEDUSA_DATABASE_SCHEMA ?? process.env.DATABASE_SCHEMA ?? "public"
and check for any other occurrences that still use || and replace them with ??
where appropriate.

In `@apps/medusa-be/package.json`:
- Line 39: The npm script setInitialPublishableKeyZerops currently pipes the raw
output of setInitialPublishableKey into zsc setSecretEnv and can write an empty
secret; change the script to capture the output of npm run -s
setInitialPublishableKey into a shell variable (e.g., TOKEN), trim/newline-strip
it, check that TOKEN is non-empty, and only then pass it to zsc setSecretEnv
(otherwise print an error and exit non‑zero); reference the npm script name
setInitialPublishableKeyZerops and the invoked command setInitialPublishableKey
and zsc setSecretEnv so the guard is added around that pipeline.

In `@apps/medusa-be/src/scripts/create-initial-superadmin.ts`:
- Around line 70-82: The code should not throw generic Error types here; replace
any remaining generic Error throws with MedusaError using
MedusaError.Types.INVALID_DATA in create-initial-superadmin.ts: specifically
update the two throw sites around the registration result checks (the branch
that checks registration.error || !registration.success and the branch that
checks !registration.authIdentity) to throw MedusaError.Types.INVALID_DATA and
include registration.error details when available in the log/message.
- Line 102: The current console.log call should be replaced with the
application's structured logger while preserving the PII-safe masked email;
change the console.log(`Superadmin is ready: ${maskEmailForLog(email)}`) to use
the project's logger (e.g., logger.info or processLogger.info) and keep
maskEmailForLog(email) so the email remains masked; update the call site where
console.log is used (related to maskEmailForLog and the email variable) to emit
an info-level log via the app logger.

In `@apps/medusa-be/src/utils/db.ts`:
- Around line 13-14: The fallback for DATABASE_URL should use the nullish
coalescing operator and the explicit default connection string as you changed;
ensure process.env.DATABASE_URL is compared with ?? (not ||) and keep the
hardcoded fallback string containing the encoded search_path, e.g.,
process.env.DATABASE_URL ??
"postgresql://root:root@medusa-db:5432/medusa?sslmode=disable&options=-csearch_path%3Dmedusa%2Cpg_catalog",
so the default only applies when DATABASE_URL is null or undefined.

In `@apps/zane-operator/project.json`:
- Around line 8-16: The review shows the added "dependsOn" and "outputs" in the
"build" target are correct but a duplicate approval comment was left; remove the
duplicate review/approval comment on this PR and keep a single approval, and if
needed add a short resolved note; locate the "build" target entry (keys
"dependsOn" and "outputs") in the project.json to confirm no further changes are
required before clearing the duplicate comment.

In `@apps/zane-operator/README.md`:
- Around line 326-424: The ordered lists in the README steps ("1. Pause preview
DB operations", "2. Export source database", "3. Restore into a staging DB on
target cluster", etc.) use increasing numeric prefixes which triggers MD029;
change every ordered-list prefix in that block so each item uses "1." (e.g.,
replace "2.", "3.", "4.", "5.", "6." with "1.") while keeping the same item text
and code fences intact to satisfy markdownlint style.

In `@apps/zane-operator/src/cli.ts`:
- Around line 1-155: The reviewer approved the changes and flagged no functional
issues; no code changes are necessary—leave parseCreateDevUserArgs (which
enforces --password-env and reads the env var), runCreateDevUser (which enforces
production grant rules), and main/error handling as-is; if you see a duplicated
review comment, remove the duplicate metadata only (no code edits).

In `@apps/zane-operator/src/main.ts`:
- Around line 92-118: The shutdown handler correctly uses try/catch/finally but
doesn't await server.stop, which can make shutdown non-deterministic; update
handleShutdown so you await server.stop(true) (or otherwise handle its returned
Promise) before awaiting sql.close, keep the try/catch/finally structure, and
retain the shuttingDown guard and process.exit(exitCode) in finally so exit
happens after both shutdown operations complete and any errors are caught.

In `@apps/zane-operator/tsconfig.json`:
- Around line 1-13: tsconfig.json looks clean after removing the redundant
"outDir"; no change required—just verify that the "compilerOptions" entries
(rootDir, module, moduleResolution, types, composite, incremental, noEmit) and
"include" match project expectations and leave as-is in
apps/zane-operator/tsconfig.json.

In `@docker/development/medusa-be/Dockerfile`:
- Around line 19-22: Refactor Dockerfile to use WORKDIR instead of RUN cd:
ensure WORKDIR /tmp/corepack appears before COPY package.json so the file lands
at /tmp/corepack/package.json, keep the RUN corepack install && rm -rf
/tmp/corepack to perform install and clean up, and finally reset the context
with WORKDIR /var/www; verify the unique directives WORKDIR, COPY package.json
/tmp/corepack/package.json, and RUN corepack install && rm -rf /tmp/corepack are
present and ordered as described.
- Line 76: The CMD currently uses a shell to run migration and entrypoint but
you must ensure the final process replaces PID 1 so signals are handled; update
the Dockerfile CMD (the CMD ["sh", "-c", "..."] line) to run migrations and
entrypoint normally and use exec only for the final start command, e.g. wrap the
chain in a single shell string: "sh -c 'npm run migrateProd && npm run
entrypoint && exec npm run start'" so exec replaces the shell with the Node
process.

In `@docker/development/n1/Dockerfile`:
- Around line 19-22: The Dockerfile sequence using WORKDIR /tmp/corepack, COPY
package.json, RUN corepack install && rm -rf /tmp/corepack, then WORKDIR
/var/www is correct and requires no code changes—leave the WORKDIR/RUN sequence
as-is (ensure the RUN retains the rm -rf /tmp/corepack cleanup and the two
WORKDIR instructions remain).

In `@README.md`:
- Around line 27-88: Summary: The review approves the README changes but
includes a duplicate reviewer tag; no code edits required. Action: leave the
README.md content as-is (the fenced code blocks and sections like "Cloud
predeploy hook (idempotent)" and the bootstrap/make targets are correct) and
remove the stray duplicate review marker from the PR metadata/comments (the
"[duplicate_comment]" tag) so there’s a single approval comment remaining; no
changes to apply-postgres-role-bootstrap.sh or
apply-zane-operator-role-bootstrap.sh are needed.

In `@scripts/apply-zane-operator-role-bootstrap.sh`:
- Around line 10-15: The bootstrap script already scopes --build to the first
docker compose run and omits it for the idempotency re-run, and it correctly
uses set -eu without bash-only pipefail; no code changes required—keep the
current commands in scripts/apply-zane-operator-role-bootstrap.sh (the docker
compose run invocation and the conditional that checks "--verify-idempotent")
and proceed to merge as-is.

ℹ️ Review info

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 18f9a18 and 0ee7dc6.

📒 Files selected for processing (22)
  • .env.docker
  • Makefile
  • README.md
  • apps/medusa-be/medusa-config.ts
  • apps/medusa-be/package.json
  • apps/medusa-be/src/scripts/create-initial-superadmin.ts
  • apps/medusa-be/src/utils/db.ts
  • apps/zane-operator/.env.example
  • apps/zane-operator/README.md
  • apps/zane-operator/project.json
  • apps/zane-operator/src/bootstrap-role.ts
  • apps/zane-operator/src/cli.ts
  • apps/zane-operator/src/config.ts
  • apps/zane-operator/src/db.ts
  • apps/zane-operator/src/handlers/teardown-preview-db.ts
  • apps/zane-operator/src/main.ts
  • apps/zane-operator/tsconfig.json
  • docker-compose.yaml
  • docker/development/medusa-be/Dockerfile
  • docker/development/n1/Dockerfile
  • scripts/apply-postgres-role-bootstrap.sh
  • scripts/apply-zane-operator-role-bootstrap.sh

Comment thread apps/medusa-be/package.json Outdated
Comment thread apps/medusa-be/package.json Outdated
Comment thread apps/zane-operator/project.json
Comment thread apps/zane-operator/README.md
Comment thread apps/zane-operator/src/bootstrap-role.ts
Comment thread apps/zane-operator/src/cli.ts Outdated
Comment thread apps/zane-operator/src/db.ts
Comment thread docker-compose.yaml
Comment thread docker/development/medusa-be/Dockerfile
@redeyecz

Copy link
Copy Markdown
Collaborator Author
  • Reviewed each finding against current code and applied only valid ones.
  • docker-compose.yaml: fixed Postgres healthcheck to use configured DB/user instead of hardcoded medusa (pg_isready -U ${DC_POSTGRES_SUPERUSER:-root} -d
    ${DC_MEDUSA_APP_DB_NAME:-medusa}).
  • apps/zane-operator/src/db.ts: added explicit owner-role existence validation before CREATE DATABASE ... OWNER ...; now throws BadRequestError with clear message if
    missing.
  • apps/zane-operator/src/cli.ts: stopped trimming --password-env value; now preserves raw password, rejects only undefined/empty, and warns (without mutation) on leading/
    trailing whitespace.
  • apps/zane-operator/src/bootstrap-role.ts: added guard to fail fast when PGUSER === BOOTSTRAP_ADMIN_PGUSER to avoid mutating/de-privileging admin account.
  • apps/zane-operator/README.md: moved DB_PREVIEW_APP_PASSWORD_SECRET into Required env vars (removed contradictory placement in Optional).
  • Env templates/examples reordered to reflect required-secret grouping for preview app password secret (where relevant).
  • apps/zane-operator/project.json: set dev target to "cache": false.
  • apps/medusa-be/package.json:
    • createInitialSuperadmin now runs with bash -euo pipefail.
    • entrypoint now documents deliberate stdout suppression for setInitialPublishableKey and adds CI opt-in output mode via EXPOSE_INITIAL_PUBLISHABLE_KEY=1.
  • CI audit: current GitHub workflows do not invoke entrypoint/publishable-key scripts yet, so no CI workflow env change was added.
    @coderabbitai

@coderabbitai

coderabbitai Bot commented Feb 25, 2026

Copy link
Copy Markdown
Contributor

Tip

For best results, initiate chat on the files or code changes.

@redeyecz Thank you for the comprehensive update! I appreciate the thorough review and selective application of the findings. The fixes look solid:

Security & robustness improvements:

  • Healthcheck now uses configured credentials
  • Owner-role validation prevents database creation failures
  • Admin account protection guard
  • Password handling preserves raw values with whitespace warnings

Documentation & configuration:

  • Required env vars properly categorized
  • Dev target caching disabled for consistency
  • CI opt-in mode documented

Error handling:

  • Proper bash -euo pipefail usage
  • Clear error messages for validation failures

The selective approach and CI audit notation show good judgment. These changes significantly harden the production DB flow and operator safety!

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 10

♻️ Duplicate comments (3)
apps/medusa-be/package.json (1)

38-39: ⚠️ Potential issue | 🟠 Major

fix: reject empty publishable-key token before persisting to Zerops

pipefail catches command failures, but it does not prevent an empty token from being written when extraction succeeds with empty content. Please add an explicit non-empty guard before zsc setSecretEnv, and tighten token extraction to avoid greedy matching.

🛠️ Proposed patch
-    "setInitialPublishableKey": "bash -euo pipefail -c 'PUBLISHABLE_KEY_TITLE=\"${INITIAL_PUBLISHABLE_KEY_NAME:-Storefront Publishable Key}\"; if [ -f ./src/scripts/output-publishable-key.ts ]; then medusa exec ./src/scripts/output-publishable-key.ts \"$PUBLISHABLE_KEY_TITLE\"; else medusa exec ./src/scripts/output-publishable-key.js \"$PUBLISHABLE_KEY_TITLE\"; fi | grep -o \"<PK_TOKEN>.*</PK_TOKEN>\" | sed \"s/<PK_TOKEN>//g;s/<\\\\/PK_TOKEN>//g\" | tr -d \"\\n\\r\"'",
-    "setInitialPublishableKeyZerops": "bash -euo pipefail -c \"npm run -s setInitialPublishableKey | tr -d '\\n\\r' | zsc setSecretEnv CHANNEL_PUBLISHABLE_KEY\""
+    "setInitialPublishableKey": "bash -euo pipefail -c 'PUBLISHABLE_KEY_TITLE=\"${INITIAL_PUBLISHABLE_KEY_NAME:-Storefront Publishable Key}\"; if [ -f ./src/scripts/output-publishable-key.ts ]; then medusa exec ./src/scripts/output-publishable-key.ts \"$PUBLISHABLE_KEY_TITLE\"; else medusa exec ./src/scripts/output-publishable-key.js \"$PUBLISHABLE_KEY_TITLE\"; fi | grep -o \"<PK_TOKEN>[^<]*</PK_TOKEN>\" | sed \"s/<PK_TOKEN>//g;s/<\\\\/PK_TOKEN>//g\" | tr -d \"\\n\\r\"'",
+    "setInitialPublishableKeyZerops": "bash -euo pipefail -c \"TOKEN=$(npm run -s setInitialPublishableKey | tr -d '\\n\\r'); [ -n \\\"$TOKEN\\\" ] || { echo 'Publishable key token is empty' >&2; exit 1; }; printf '%s' \\\"$TOKEN\\\" | zsc setSecretEnv CHANNEL_PUBLISHABLE_KEY\""
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/medusa-be/package.json` around lines 38 - 39, Tighten the token
extraction in the setInitialPublishableKey script to avoid greedy matching (use
a pattern that only captures token content, e.g., replace "grep -o
\"<PK_TOKEN>.*</PK_TOKEN>\" | sed ..." with a non-greedy/negative-match pattern
that yields only the token) and add an explicit non-empty guard in
setInitialPublishableKeyZerops: capture the output of npm run
setInitialPublishableKey into a variable, trim it, test that it's non-empty
(fail/exit non-zero if empty) before calling zsc setSecretEnv, so empty tokens
are rejected before persisting to Zerops.
apps/zane-operator/README.md (1)

330-424: docs(style): ordered list prefixes still trigger MD029.

This was flagged in a previous review iteration. Lines 330, 342, 362, 366, and 424 use incremental prefixes (2., 3., etc.) instead of the 1/1/1 style expected by markdownlint. Either normalise to 1. prefixes throughout or configure markdownlint to accept ordered style.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/zane-operator/README.md` around lines 330 - 424, The ordered list items
in the README (e.g., the step headings "2. Export source database", "3. Restore
into a staging DB on target cluster", and subsequent numbered blocks) use
incremental prefixes which trigger MD029; update all ordered list prefixes to
use a consistent "1." style for every list item (or alternatively add a
markdownlint configuration rule to allow incremental numbering) so the sections
around those headings and the numbered command blocks all use the normalized
numbering expected by markdownlint.
apps/zane-operator/src/db.ts (1)

31-35: quoteCatalogIdentifier still lacks validation guard

This was raised previously and remains open. The function skips assertSafeIdentifier, which is a concern for any future caller passing non-catalog-sourced input.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/zane-operator/src/db.ts` around lines 31 - 35, quoteCatalogIdentifier
currently skips assertSafeIdentifier which risks accidental use with untrusted
input; update quoteCatalogIdentifier to perform a validation guard by calling
assertSafeIdentifier(identifier) at the start (or add an explicit boolean
parameter like allowUnsafe=false and call assertSafeIdentifier when false is not
set) so callers must intentionally bypass validation; ensure the function's
JSDoc/comments reflect that only catalog-trusted values are allowed and adjust
any call sites that intentionally bypass validation to pass the explicit flag or
be audited.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In @.env.docker:
- Around line 49-54: Update the MinIO credentials in .env.docker so they match
the medusa-minio container root credentials: replace the DC_MINIO_ACCESS_KEY and
DC_MINIO_SECRET_KEY values (currently minioadminkey) with the root credentials
configured in docker-compose.yaml (MINIO_ROOT_USER and MINIO_ROOT_PASSWORD,
e.g., minioadmin). Ensure DC_MINIO_ACCESS_KEY and DC_MINIO_SECRET_KEY exactly
match those root values so Medusa's S3 client can authenticate to the
medusa-minio container.

In `@apps/zane-operator/README.md`:
- Around line 344-360: The README shows the TEMPLATE_STAGING_DB variable
(constructed with date) being interpolated directly into the CREATE DATABASE SQL
(CREATE DATABASE ${TEMPLATE_STAGING_DB}), which can be risky if the variable
ever comes from user input; update the docs to add a short caution advising that
TEMPLATE_STAGING_DB must not be populated from untrusted input, recommend
validating/sanitizing the name (e.g., restrict to a safe regex for allowed
characters and a fixed prefix) before interpolation, and mention that
quoting/escaping identifiers or using controlled server-side naming is preferred
when generating database names; reference TEMPLATE_STAGING_DB, date, and the
CREATE DATABASE command in the guidance so readers can find and harden the
example.

In `@apps/zane-operator/src/bootstrap-role.ts`:
- Around line 63-70: readRequiredEnv currently calls .trim() on every value,
which mutates sensitive values like targetPassword; change readRequiredEnv to
accept an optional flag (e.g., preserveWhitespace: boolean = false) or a second
parameter to skip trimming and only call .trim() when preserveWhitespace is
false, update the call site that reads targetPassword to call
readRequiredEnv(..., true) so the raw password is preserved, and ensure other
callers keep the existing trimming behavior; reference readRequiredEnv and
targetPassword (and mirror the cli.ts approach) when making the update.

In `@apps/zane-operator/src/cli.ts`:
- Around line 149-161: The catch block after calling main should retain the
special-case handling for BadRequestError (printing error.message and calling
printUsage()), but for all other errors (the branch that currently logs only
message and exits) update it to log the full stack/diagnostic by printing
error.stack when available (falling back to error.message or String(error) if
not), e.g., use the caught variable (error) to prefer (error instanceof Error ?
error.stack : String(error)) in console.error before process.exit(1); leave
function names main and printUsage and the BadRequestError check unchanged.

In `@apps/zane-operator/src/db.ts`:
- Around line 579-583: The interface EnsurePreviewDatabaseParams is not exported
but is the third parameter type of the exported function ensurePreviewDatabase;
export the interface so callers can import it directly by adding an export
modifier to EnsurePreviewDatabaseParams and keep its fields (prNumber,
templateDatabase, owner) unchanged, then update any local references if needed
to use the exported symbol instead of Parameters<typeof
ensurePreviewDatabase>[2].
- Around line 483-504: The advisory lock uses hashtext(lockKey) (32-bit) which
risks collisions; update withAdvisoryLock to compute a 64-bit lock key (either
by splitting a 64-bit hash into two 32-bit ints and calling
pg_advisory_lock/pg_advisory_unlock with two arguments, or by converting
md5(lockKey) to a 64-bit bigint like ('x' || md5(lockKey))::bit(64)::bigint) and
use that same 64-bit form in both the pg_advisory_lock and pg_advisory_unlock
calls inside withAdvisoryLock (references: function withAdvisoryLock,
reservedSql, lockKey, and the pg_advisory_lock/pg_advisory_unlock invocations).
- Around line 679-710: The catch in dropDatabase silently handles a "syntax
error at or near \"with\"" fallback that is unreachable on PostgreSQL 18+, so
remove the entire conditional branch that checks
normalizedMessage.includes("syntax error at or near \"with\"") and the fallback
await sql.unsafe(`DROP DATABASE IF EXISTS ${quotedDbName};`); instead let
genuine syntax errors surface (or rethrow the original error) and optionally add
a short comment in dropDatabase explaining that DROP ... WITH (FORCE) is
supported on PG >=13 and the fallback was removed because we require PG18+.
- Around line 147-165: Remove the invalid explicit connect call on the Bun SQL
client: in withDatabaseClientByUrl replace the await databaseSql.connect() usage
by directly calling return await operation(databaseSql) so the pool connects
lazily, leaving the finally block to call await databaseSql.close({ timeout: 5
}); update the databaseSql.connect reference (and any identical calls found in
main.ts, cli.ts, bootstrap-role.ts) to the same pattern while keeping
buildDatabaseUrl, the SQL instantiation, operation(databaseSql) and the
close(...) logic intact.

In `@apps/zane-operator/src/pg-utils.ts`:
- Around line 33-35: The quoteLiteral function must reject strings containing
null bytes; update quoteLiteral(value: string) to first detect
value.includes('\0') (or value.indexOf('\0') !== -1) and throw a clear Error
(e.g., "quoteLiteral: input contains null byte") before performing the existing
single-quote escaping and wrapping, preserving the current replacement logic
(value.replaceAll("'", "''")) and return format.

In `@docker-compose.yaml`:
- Around line 1-5: Add a brief inline comment explaining that
x-medusa-bootstrap-db-env uses YAML merge key syntax (<<: *medusa-app-db-env) to
inherit MEDUSA_APP_DB_* entries from the x-medusa-app-db-env anchor and then
overrides/extends them with MEDUSA_DEV_DB_USER and MEDUSA_DEV_DB_PASSWORD;
update the medusa-bootstrap-db-env block to include this comment next to the <<:
*medusa-app-db-env merge so readers understand the merge semantics and which
keys are being inherited versus overridden.

---

Duplicate comments:
In `@apps/medusa-be/package.json`:
- Around line 38-39: Tighten the token extraction in the
setInitialPublishableKey script to avoid greedy matching (use a pattern that
only captures token content, e.g., replace "grep -o \"<PK_TOKEN>.*</PK_TOKEN>\"
| sed ..." with a non-greedy/negative-match pattern that yields only the token)
and add an explicit non-empty guard in setInitialPublishableKeyZerops: capture
the output of npm run setInitialPublishableKey into a variable, trim it, test
that it's non-empty (fail/exit non-zero if empty) before calling zsc
setSecretEnv, so empty tokens are rejected before persisting to Zerops.

In `@apps/zane-operator/README.md`:
- Around line 330-424: The ordered list items in the README (e.g., the step
headings "2. Export source database", "3. Restore into a staging DB on target
cluster", and subsequent numbered blocks) use incremental prefixes which trigger
MD029; update all ordered list prefixes to use a consistent "1." style for every
list item (or alternatively add a markdownlint configuration rule to allow
incremental numbering) so the sections around those headings and the numbered
command blocks all use the normalized numbering expected by markdownlint.

In `@apps/zane-operator/src/db.ts`:
- Around line 31-35: quoteCatalogIdentifier currently skips assertSafeIdentifier
which risks accidental use with untrusted input; update quoteCatalogIdentifier
to perform a validation guard by calling assertSafeIdentifier(identifier) at the
start (or add an explicit boolean parameter like allowUnsafe=false and call
assertSafeIdentifier when false is not set) so callers must intentionally bypass
validation; ensure the function's JSDoc/comments reflect that only
catalog-trusted values are allowed and adjust any call sites that intentionally
bypass validation to pass the explicit flag or be audited.

ℹ️ Review info

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 0ee7dc6 and 8326f71.

📒 Files selected for processing (10)
  • .env.docker
  • apps/medusa-be/package.json
  • apps/zane-operator/.env.example
  • apps/zane-operator/README.md
  • apps/zane-operator/project.json
  • apps/zane-operator/src/bootstrap-role.ts
  • apps/zane-operator/src/cli.ts
  • apps/zane-operator/src/db.ts
  • apps/zane-operator/src/pg-utils.ts
  • docker-compose.yaml

Comment thread .env.docker
Comment thread apps/zane-operator/README.md
Comment thread apps/zane-operator/src/bootstrap-role.ts
Comment thread apps/zane-operator/src/cli.ts
Comment thread apps/zane-operator/src/db.ts
Comment thread apps/zane-operator/src/db.ts
Comment thread apps/zane-operator/src/db.ts Outdated
Comment thread apps/zane-operator/src/db.ts
Comment thread apps/zane-operator/src/pg-utils.ts
Comment thread docker-compose.yaml

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@docker-compose.yaml`:
- Around line 217-220: Update the Postgres volume fallback so it uses the PG18
data layout instead of the legacy path: change the default for
DC_POSTGRES_VOLUME_DATA in the volume mapping "-
${DC_POSTGRES_VOLUME_DATA:-./.docker_data/db}:/var/lib/postgresql" to the PG18
path used elsewhere (for example "./data/postgres"), ensuring the variable name
DC_POSTGRES_VOLUME_DATA and the volume mapping to /var/lib/postgresql are
preserved.

In `@README.md`:
- Around line 27-33: Update the quick-start note that currently says to run
`make dev` to explicitly warn users to populate the required operator secrets in
`.env.docker` before starting (since docker-compose uses required expansion and
will fail if those vars are empty); add a short bullet listing the specific
operator-related env vars to set (the secrets used by
`zane-operator`/`zane-operator-bootstrap`) and an example placeholder format,
and state this must be done prior to running `make dev`.

ℹ️ Review info

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 8326f71 and 0f3655d.

📒 Files selected for processing (4)
  • .env.docker
  • Makefile
  • README.md
  • docker-compose.yaml

Comment thread docker-compose.yaml
Comment thread README.md

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
README.md (1)

83-101: ⚠️ Potential issue | 🟡 Minor

fix(readme): resolve ordered-list prefix conflict (MD029)

Line 101 is flagged because the ordered sub-list in Lines 83-89 collides with the main numbered setup flow. Use bullets for the manual subsection to avoid numbering carry-over.

📝 Proposed fix
-1. Open live `.env`.
-2. Set medusa-be DB connection values to APP credentials (`medusa_app`-style account), not superuser credentials.
-3. Ensure DB host, port, and database match the compose service defaults used in your environment.
-4. Keep operator-only credentials separate from app credentials.
-5. Restart services that consume `.env`.
-6. Validate with one read and one write operation from medusa-be.
+- Open live `.env`.
+- Set medusa-be DB connection values to APP credentials (`medusa_app`-style account), not superuser credentials.
+- Ensure DB host, port, and database match the compose service defaults used in your environment.
+- Keep operator-only credentials separate from app credentials.
+- Restart services that consume `.env`.
+- Validate with one read and one write operation from medusa-be.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@README.md` around lines 83 - 101, The README has an ordered-list prefix
conflict (MD029) because the manual subsection items ("Migrate database",
"Create user for medusa admin", "Prepare file storage") are numbered and collide
with the main numbered setup flow; change that subsection to use an unordered
list (e.g., replace the "4. <b>Migrate database</b>", "5. <b>Create user for
medusa admin</b>", "6. <b>Prepare file storage</b>" entries with bullets or
hyphens) while preserving the code blocks (make medusa-migrate, make
medusa-create-user) and HTML/markup formatting so the nested steps render as a
manual/bullet subsection rather than continuing the main numbering.
♻️ Duplicate comments (1)
docker-compose.yaml (1)

249-249: ⚠️ Potential issue | 🟡 Minor

fix(compose): default Postgres data path still points to legacy location

Line 249 still defaults DC_POSTGRES_VOLUME_DATA to ./.docker_data/db, which can drift from your PG18 flow/documentation and lead to data ending up in an unexpected path.

🔧 Proposed fix
-      - ${DC_POSTGRES_VOLUME_DATA:-./.docker_data/db}:/var/lib/postgresql
+      - ${DC_POSTGRES_VOLUME_DATA:-./.docker_data/db18}:/var/lib/postgresql
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docker-compose.yaml` at line 249, The docker-compose volume mapping still
defaults DC_POSTGRES_VOLUME_DATA to the legacy path "./.docker_data/db"; update
the default to the current PG18-consistent path by changing the default value
used for the environment variable DC_POSTGRES_VOLUME_DATA in the docker-compose
service definition (the "-
${DC_POSTGRES_VOLUME_DATA:-./.docker_data/db}:/var/lib/postgresql" mapping) to
the correct new default path used by the project so volumes land in the expected
location.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In `@README.md`:
- Around line 83-101: The README has an ordered-list prefix conflict (MD029)
because the manual subsection items ("Migrate database", "Create user for medusa
admin", "Prepare file storage") are numbered and collide with the main numbered
setup flow; change that subsection to use an unordered list (e.g., replace the
"4. <b>Migrate database</b>", "5. <b>Create user for medusa admin</b>", "6.
<b>Prepare file storage</b>" entries with bullets or hyphens) while preserving
the code blocks (make medusa-migrate, make medusa-create-user) and HTML/markup
formatting so the nested steps render as a manual/bullet subsection rather than
continuing the main numbering.

---

Duplicate comments:
In `@docker-compose.yaml`:
- Line 249: The docker-compose volume mapping still defaults
DC_POSTGRES_VOLUME_DATA to the legacy path "./.docker_data/db"; update the
default to the current PG18-consistent path by changing the default value used
for the environment variable DC_POSTGRES_VOLUME_DATA in the docker-compose
service definition (the "-
${DC_POSTGRES_VOLUME_DATA:-./.docker_data/db}:/var/lib/postgresql" mapping) to
the correct new default path used by the project so volumes land in the expected
location.

ℹ️ Review info

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 0f3655d and 7823576.

📒 Files selected for processing (2)
  • README.md
  • docker-compose.yaml

diff --git c/.env.docker i/.env.docker
index a215d55d..fa88cba2 100644
--- c/.env.docker
+++ i/.env.docker
@@ -16,8 +16,15 @@ DC_N1_MEDUSA_RESEND_FROM_EMAIL=onboarding@resend.dev
 #Medusa BE container
 DC_JWT_SECRET=supersecret
 DC_COOKIE_SECRET=supersecret
-DC_DATABASE_URL=postgresql://root:root@medusa-db:5432/medusa?sslmode=disable
+DC_DATABASE_URL=postgresql://medusa_app:medusa_app_change_me@medusa-db:5432/medusa?sslmode=disable
 DC_LEGACY_DATABASE_URL=mysql://root:1234@engine-db:3306/n1shop
+DC_ZANE_OPERATOR_DB_USER=zane_operator
+DC_ZANE_OPERATOR_DB_PASSWORD=zane_operator_change_me
+DC_MEDUSA_APP_DB_USER=medusa_app
+DC_MEDUSA_APP_DB_PASSWORD=medusa_app_change_me
+DC_MEDUSA_DEV_DB_USER=medusa_dev
+DC_MEDUSA_DEV_DB_PASSWORD=medusa_dev_change_me
+DC_MEDUSA_APP_DB_NAME=medusa
 DC_MINIO_FILE_URL="http://medusa-minio:9004/medusa-bucket"
 DC_MINIO_REGION="us-east-1"
 DC_MINIO_BUCKET="medusa-bucket"
diff --git c/.github/workflows/deploy-zaneops-preview.yml i/.github/workflows/deploy-zaneops-preview.yml
new file mode 100644
index 00000000..073834bc
--- /dev/null
+++ i/.github/workflows/deploy-zaneops-preview.yml
@@ -0,0 +1,41 @@
+name: ZaneOps Preview Deploy
+
+on:
+  workflow_dispatch:
+  # pull_request:
+  #   types: [opened, reopened, synchronize, closed]
+
+permissions:
+  contents: read
+
+jobs:
+  preview-wiring-template:
+    runs-on: ubuntu-latest
+    steps:
+      - name: Wiring prepared but disabled
+        run: |
+          echo "Preview zane-operator wiring is intentionally commented out."
+
+      # - name: Ensure preview DB for opened/reopened/synchronize
+      #   if: github.event.action != 'closed'
+      #   env:
+      #     ZANE_OPERATOR_BASE_URL: ${{ secrets.ZANEOPS_ZANE_OPERATOR_BASE_URL }}
+      #     ZANE_OPERATOR_API_TOKEN: ${{ secrets.ZANEOPS_ZANE_OPERATOR_API_TOKEN }}
+      #   run: |
+      #     curl --fail --show-error --silent \
+      #       --retry 3 --retry-all-errors --retry-delay 2 \
+      #       -X POST "${ZANE_OPERATOR_BASE_URL}/v1/preview-db/ensure" \
+      #       -H "Authorization: Bearer ${ZANE_OPERATOR_API_TOKEN}" \
+      #       -H "Content-Type: application/json" \
+      #       -d "{\"pr_number\":${{ github.event.pull_request.number }}}"
+
+      # - name: Teardown preview DB on PR close
+      #   if: github.event.action == 'closed'
+      #   env:
+      #     ZANE_OPERATOR_BASE_URL: ${{ secrets.ZANEOPS_ZANE_OPERATOR_BASE_URL }}
+      #     ZANE_OPERATOR_API_TOKEN: ${{ secrets.ZANEOPS_ZANE_OPERATOR_API_TOKEN }}
+      #   run: |
+      #     curl --fail --show-error --silent \
+      #       --retry 3 --retry-all-errors --retry-delay 2 \
+      #       -X DELETE "${ZANE_OPERATOR_BASE_URL}/v1/preview-db/${{ github.event.pull_request.number }}" \
+      #       -H "Authorization: Bearer ${ZANE_OPERATOR_API_TOKEN}"
diff --git c/README.md i/README.md
index ef327b02..5fed5a83 100644
--- c/README.md
+++ i/README.md
@@ -27,6 +27,11 @@
     ```shell
     make dev
     ```
+    * Postgres role bootstrap (`zane_operator`, `medusa_app`, `medusa_dev`) runs automatically on first DB initialization via `docker/development/postgres/initdb/01-zane-role-bootstrap.sh`
+    * If your Postgres volume already existed before this change, apply bootstrap manually once:
+    ```shell
+    ./scripts/apply-postgres-role-bootstrap.sh
+    ```

 4. <b>Migrate database</b> (if needed)
     * <i>(optional)</i> `medusa` schema needs to exist, which it should, unless it was manually dropped
diff --git c/apps/zane-operator/.env.example i/apps/zane-operator/.env.example
new file mode 100644
index 00000000..dadb95e2
--- /dev/null
+++ i/apps/zane-operator/.env.example
@@ -0,0 +1,20 @@
+# Server
+PORT=8080
+API_AUTH_TOKEN=replace-with-strong-random-token
+
+# Postgres connection used by zane-operator
+PGHOST=postgres.internal
+PGPORT=5432
+PGUSER=zane_operator
+PGPASSWORD=replace-with-db-password
+PGDATABASE=postgres
+PGSSLMODE=disable
+
+# Preview DB behavior
+DB_TEMPLATE_NAME=template_medusa
+DB_PREVIEW_PREFIX=medusa_pr_
+DB_PREVIEW_OWNER=zane_operator
+DB_PREVIEW_APP_USER_PREFIX=medusa_pr_app_
+DB_PREVIEW_DEV_ROLE=medusa_dev
+DB_PREVIEW_APP_PASSWORD_SECRET=replace-with-strong-random-secret
+DB_PROTECTED_NAMES=demo,postgres,template0,template1,template_medusa
diff --git c/apps/zane-operator/README.md i/apps/zane-operator/README.md
new file mode 100644
index 00000000..bb950928
--- /dev/null
+++ i/apps/zane-operator/README.md
@@ -0,0 +1,344 @@
+# zane-operator
+
+Internal Bun service for preview PostgreSQL database lifecycle operations.
+
+## Endpoints
+
+- `GET /healthz`
+- `POST /v1/preview-db/ensure`
+- `DELETE /v1/preview-db/{pr_number}`
+
+All `/v1/*` endpoints require:
+
+- `Authorization: Bearer <API_AUTH_TOKEN>`
+
+## Ensure payload
+
+```json
+{
+  "pr_number": 123
+}
+```
+
+`template_db` and `owner` request overrides are disabled. The service always uses configured defaults.
+
+Ensure response includes generated per-preview app credentials:
+
+```json
+{
+  "db_name": "medusa_pr_123",
+  "created": true,
+  "app_user": "medusa_pr_app_123",
+  "app_password": "..."
+}
+```
+
+Teardown response includes role cleanup result:
+
+```json
+{
+  "db_name": "medusa_pr_123",
+  "deleted": true,
+  "app_user": "medusa_pr_app_123",
+  "role_deleted": true
+}
+```
+
+## Required env vars
+
+- `API_AUTH_TOKEN`
+- `PGHOST`
+- `PGUSER`
+- `PGPASSWORD`
+
+## Optional env vars
+
+- `PORT` (default: `8080`)
+- `PGPORT` (default: `5432`)
+- `PGDATABASE` (default: `postgres`)
+- `PGSSLMODE` (default: `disable`)
+- `DB_TEMPLATE_NAME` (default: `template_medusa`)
+- `DB_PREVIEW_PREFIX` (default: `medusa_pr_`)
+- `DB_PREVIEW_OWNER` (default: `zane_operator`)
+- `DB_PREVIEW_APP_USER_PREFIX` (default: `medusa_pr_app_`)
+- `DB_PREVIEW_DEV_ROLE` (default: `medusa_dev`)
+- `DB_PREVIEW_APP_PASSWORD_SECRET` (defaults to `API_AUTH_TOKEN`, but set explicitly in production)
+- `DB_PROTECTED_NAMES` (extra protected DB names, comma-separated)
+
+## Onboarding
+
+### 1. Create database role with required permissions
+
+For local Docker Compose environments, role bootstrap is automated by:
+- `docker/development/postgres/initdb/01-zane-role-bootstrap.sh`
+
+For existing Postgres volumes (already initialized before bootstrap script was added), apply once:
+
+```bash
+./scripts/apply-postgres-role-bootstrap.sh
+```
+
+Run as a PostgreSQL admin role:
+
+```sql
+CREATE ROLE zane_operator LOGIN PASSWORD 'replace-with-strong-password' CREATEDB;
+GRANT pg_signal_backend TO zane_operator;
+```
+
+`zane_operator` must be able to:
+- connect to the server (`PGHOST`/`PGPORT`)
+- create preview DBs (`CREATEDB`)
+- drop preview DBs it owns
+- terminate active DB sessions during teardown (`pg_signal_backend`)
+- clone from template DB (`template_medusa`)
+
+`medusa_dev` (or your configured `DB_PREVIEW_DEV_ROLE`) must exist. `ensure` grants it connect+schema/table access on each preview DB.
+
+For `CREATE DATABASE ... WITH TEMPLATE ...`, the operator must be superuser or own the template DB.
+Preferred setup is ownership transfer of the template DB:
+
+```sql
+ALTER DATABASE template_medusa OWNER TO zane_operator;
+```
+
+If you cannot transfer ownership, set `PGUSER` to a role that is allowed to clone from `template_medusa`.
+
+Important runtime assumption:
+- preview grant/ownership sync expects cloned objects to be owned by the executing role for zane-operator (normally `zane_operator` when following this guide)
+- if template object ownership differs, preview app role ownership transfer may be partial and you may need to normalize template owners before enabling automation
+
+### 2. Configure service environment
+
+Use `apps/zane-operator/.env.example` as baseline.
+
+Required production values:
+- `API_AUTH_TOKEN` must be a strong random secret
+- `PGHOST`, `PGPORT`, `PGUSER`, `PGPASSWORD`, `PGDATABASE`
+- `DB_TEMPLATE_NAME=template_medusa`
+- `DB_PREVIEW_PREFIX=medusa_pr_`
+- `DB_PREVIEW_OWNER=zane_operator`
+- `DB_PREVIEW_APP_USER_PREFIX=medusa_pr_app_`
+- `DB_PREVIEW_DEV_ROLE=medusa_dev`
+- `DB_PREVIEW_APP_PASSWORD_SECRET=<long-random-secret>`
+
+### 3. Smoke test before deployment
+
+Start service locally:
+
+```bash
+cd apps/zane-operator
+bun run start
+```
+
+Healthcheck:
+
+```bash
+curl -fsS http://localhost:8080/healthz
+```
+
+Ensure preview DB:
+
+```bash
+curl --fail --show-error --silent \
+  -X POST "http://localhost:8080/v1/preview-db/ensure" \
+  -H "Authorization: Bearer ${API_AUTH_TOKEN}" \
+  -H "Content-Type: application/json" \
+  -d '{"pr_number":123}'
+```
+
+Teardown preview DB:
+
+```bash
+curl --fail --show-error --silent \
+  -X DELETE "http://localhost:8080/v1/preview-db/123" \
+  -H "Authorization: Bearer ${API_AUTH_TOKEN}"
+```
+
+### 4. Prepare deployment artifacts
+
+Build compiled binary:
+
+```bash
+cd apps/zane-operator
+bun run build
+```
+
+Container build (from repository root):
+
+```bash
+docker build -f docker/development/zane-operator/Dockerfile -t zane-operator:latest .
+```
+
+### 5. Run Docker image
+
+Run the container with environment loaded from an env file:
+
+```bash
+docker run --rm --name zane-operator \
+  -p 8080:8080 \
+  --env-file .env \
+  zane-operator:latest
+```
+
+Required values in `.env` for this container:
+- `API_AUTH_TOKEN`
+- `PGHOST`
+- `PGPORT`
+- `PGUSER`
+- `PGPASSWORD`
+- `PGDATABASE`
+- `PORT` (optional, defaults to `8080`)
+- `DB_TEMPLATE_NAME` (optional)
+- `DB_PREVIEW_PREFIX` (optional)
+- `DB_PREVIEW_OWNER` (optional)
+- `DB_PREVIEW_APP_USER_PREFIX` (optional)
+- `DB_PREVIEW_DEV_ROLE` (optional)
+- `DB_PREVIEW_APP_PASSWORD_SECRET` (recommended)
+
+If `medusa-db` is in Docker Compose, set `PGHOST` in `.env` to the Compose service name (usually `medusa-db`) and run this container on the same Docker network.
+
+Smoke test:
+
+```bash
+curl -fsS http://localhost:8080/healthz
+```
+
+### 6. Prepare GitHub Actions integration
+
+Set repository secrets:
+- `ZANEOPS_ZANE_OPERATOR_BASE_URL`
+- `ZANEOPS_ZANE_OPERATOR_API_TOKEN`
+
+Preview workflow template is present in:
+- `.github/workflows/deploy-zaneops-preview.yml`
+
+zane-operator calls are intentionally commented out for now. Uncomment them when you are ready to enable preview DB lifecycle from PR events.
+
+### 7. Manually refresh `template_medusa` from a different source DB
+
+Use this flow when you want preview DBs to be cloned from a new upstream data snapshot.
+
+Prerequisites:
+- PostgreSQL client tools available (`pg_dump`, `pg_restore`, `psql`)
+- root-level PostgreSQL role access on the target cluster (role `root` or equivalent)
+- ability to pause preview creation during refresh window
+
+The commands below assume host-provided PostgreSQL environment defaults are already available (Docker Compose context), especially `POSTGRES_DB`, `POSTGRES_USER`, and `POSTGRES_PASSWORD`.
+
+1. Pause preview DB operations
+- temporarily disable zane-operator deployment hooks/workflow calls
+- ensure no new `/v1/preview-db/ensure` requests are running
+
+2. Export source database
+
+```bash
+PGPASSWORD="$POSTGRES_PASSWORD" pg_dump \
+  -U "$POSTGRES_USER" \
+  -d "$POSTGRES_DB" \
+  --format=custom \
+  --no-owner \
+  --no-privileges \
+  --file /tmp/template_medusa.dump
+```
+
+3. Restore into a staging DB on target cluster
+
+```bash
+TEMPLATE_STAGING_DB="template_medusa_staging_$(date +%Y%m%d%H%M%S)"
+
+PGPASSWORD="$POSTGRES_PASSWORD" psql \
+  -U "$POSTGRES_USER" \
+  -d "$POSTGRES_DB" \
+  -v ON_ERROR_STOP=1 \
+  -c "CREATE DATABASE ${TEMPLATE_STAGING_DB} OWNER zane_operator;"
+PGPASSWORD="$POSTGRES_PASSWORD" pg_restore \
+  --username "$POSTGRES_USER" \
+  --dbname "$TEMPLATE_STAGING_DB" \
+  --clean \
+  --if-exists \
+  --no-owner \
+  --no-privileges \
+  /tmp/template_medusa.dump
+```
+
+4. Validate staging content
+- run critical checks in `${TEMPLATE_STAGING_DB}` (schema, core tables, expected row counts)
+- verify app compatibility (migrations/scripts expected by preview environments)
+
+5. Swap staging into `template_medusa`
+
+```bash
+TEMPLATE_EXISTS="$(PGPASSWORD="$POSTGRES_PASSWORD" psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -tA -c "SELECT 1 FROM pg_database WHERE datname = 'template_medusa'")"
+if [ "$TEMPLATE_EXISTS" = "1" ]; then
+  PGPASSWORD="$POSTGRES_PASSWORD" psql \
+    -U "$POSTGRES_USER" \
+    -d "$POSTGRES_DB" \
+    -v ON_ERROR_STOP=1 <<'SQL'
+  SELECT pg_terminate_backend(pid)
+  FROM pg_stat_activity
+  WHERE datname = 'template_medusa'
+    AND pid <> pg_backend_pid();
+SQL
+
+  PGPASSWORD="$POSTGRES_PASSWORD" psql \
+    -U "$POSTGRES_USER" \
+    -d "$POSTGRES_DB" \
+    -v ON_ERROR_STOP=1 \
+    -c "DROP DATABASE template_medusa;"
+fi
+
+STAGING_EXISTS="$(PGPASSWORD="$POSTGRES_PASSWORD" psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -tA -c "SELECT 1 FROM pg_database WHERE datname = '${TEMPLATE_STAGING_DB}'")"
+if [ "$STAGING_EXISTS" = "1" ]; then
+  PGPASSWORD="$POSTGRES_PASSWORD" psql \
+    -U "$POSTGRES_USER" \
+    -d "$POSTGRES_DB" \
+    -v ON_ERROR_STOP=1 \
+    -c "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = '${TEMPLATE_STAGING_DB}' AND pid <> pg_backend_pid();"
+
+  PGPASSWORD="$POSTGRES_PASSWORD" psql \
+    -U "$POSTGRES_USER" \
+    -d "$POSTGRES_DB" \
+    -v ON_ERROR_STOP=1 \
+    -c "ALTER DATABASE ${TEMPLATE_STAGING_DB} RENAME TO template_medusa;"
+else
+  PGPASSWORD="$POSTGRES_PASSWORD" psql \
+    -U "$POSTGRES_USER" \
+    -d "$POSTGRES_DB" \
+    -v ON_ERROR_STOP=1 \
+    -c "CREATE DATABASE template_medusa OWNER zane_operator;"
+  PGPASSWORD="$POSTGRES_PASSWORD" pg_restore \
+    --username "$POSTGRES_USER" \
+    --dbname "template_medusa" \
+    --clean \
+    --if-exists \
+    --no-owner \
+    --no-privileges \
+    /tmp/template_medusa.dump
+fi
+
+PGPASSWORD="$POSTGRES_PASSWORD" psql \
+  -U "$POSTGRES_USER" \
+  -d "$POSTGRES_DB" \
+  -v ON_ERROR_STOP=1 \
+  -c "ALTER DATABASE template_medusa OWNER TO zane_operator;"
+```
+
+6. Re-enable preview operations and verify
+- re-enable workflow/hooks calling zane-operator
+- run one ensure/teardown smoke test against a throwaway PR number:
+
+```bash
+export ZANE_OPERATOR_BASE_URL="http://localhost:8080"
+
+curl --fail --show-error --silent \
+  -X POST "${ZANE_OPERATOR_BASE_URL}/v1/preview-db/ensure" \
+  -H "Authorization: Bearer ${API_AUTH_TOKEN}" \
+  -H "Content-Type: application/json" \
+  -d '{"pr_number":999999}'
+
+curl --fail --show-error --silent \
+  -X DELETE "${ZANE_OPERATOR_BASE_URL}/v1/preview-db/999999" \
+  -H "Authorization: Bearer ${API_AUTH_TOKEN}"
+```
+
+Critical note: this process drops and replaces `template_medusa`. Run it in a controlled maintenance window and keep a backup dump of the previous template if rollback may be needed.
diff --git c/apps/zane-operator/package.json i/apps/zane-operator/package.json
new file mode 100644
index 00000000..0634e474
--- /dev/null
+++ i/apps/zane-operator/package.json
@@ -0,0 +1,12 @@
+{
+  "name": "zane-operator",
+  "version": "0.0.1",
+  "private": true,
+  "type": "module",
+  "scripts": {
+    "dev": "bun --watch src/main.ts",
+    "start": "bun src/main.ts",
+    "build": "bun build src/main.ts --compile --outfile dist/zane-operator",
+    "typecheck": "bunx tsc --noEmit -p tsconfig.json"
+  }
+}
diff --git c/apps/zane-operator/project.json i/apps/zane-operator/project.json
new file mode 100644
index 00000000..f40ffd62
--- /dev/null
+++ i/apps/zane-operator/project.json
@@ -0,0 +1,30 @@
+{
+  "$schema": "../../node_modules/nx/schemas/project-schema.json",
+  "name": "zane-operator",
+  "projectType": "application",
+  "sourceRoot": "apps/zane-operator/src",
+  "tags": ["type:app", "platform:backend", "framework:agnostic"],
+  "targets": {
+    "build": {
+      "executor": "nx:run-commands",
+      "options": {
+        "cwd": "apps/zane-operator",
+        "command": "bun run build"
+      }
+    },
+    "typecheck": {
+      "executor": "nx:run-commands",
+      "options": {
+        "cwd": "apps/zane-operator",
+        "command": "bun run typecheck"
+      }
+    },
+    "dev": {
+      "executor": "nx:run-commands",
+      "options": {
+        "cwd": "apps/zane-operator",
+        "command": "bun run dev"
+      }
+    }
+  }
+}
diff --git c/apps/zane-operator/src/auth.ts i/apps/zane-operator/src/auth.ts
new file mode 100644
index 00000000..08e94476
--- /dev/null
+++ i/apps/zane-operator/src/auth.ts
@@ -0,0 +1,38 @@
+import { timingSafeEqual } from "node:crypto"
+
+import { jsonError } from "./http"
+
+function safeTokenCompare(expected: string, actual: string): boolean {
+  const expectedBytes = Buffer.from(expected)
+  const actualBytes = Buffer.from(actual)
+
+  if (expectedBytes.length !== actualBytes.length) {
+    return false
+  }
+
+  return timingSafeEqual(expectedBytes, actualBytes)
+}
+
+export function enforceBearerToken(request: Request, expectedToken: string): Response | null {
+  const authorization = request.headers.get("authorization")
+
+  if (!authorization) {
+    return jsonError(401, "missing_authorization", "Authorization header is required")
+  }
+
+  const tokenMatch = /^Bearer\s+(.+)$/i.exec(authorization)
+  if (!tokenMatch) {
+    return jsonError(401, "invalid_authorization", "Authorization header must use Bearer token")
+  }
+
+  const token = tokenMatch[1]?.trim()
+  if (!token) {
+    return jsonError(401, "invalid_authorization", "Bearer token cannot be empty")
+  }
+
+  if (!safeTokenCompare(expectedToken, token)) {
+    return jsonError(403, "forbidden", "Invalid API token")
+  }
+
+  return null
+}
diff --git c/apps/zane-operator/src/config.ts i/apps/zane-operator/src/config.ts
new file mode 100644
index 00000000..9f444aa6
--- /dev/null
+++ i/apps/zane-operator/src/config.ts
@@ -0,0 +1,129 @@
+const DEFAULT_PORT = 8080
+const DEFAULT_PG_PORT = 5432
+const DEFAULT_PG_DATABASE = "postgres"
+const DEFAULT_PG_SSL_MODE = "disable"
+const DEFAULT_DB_TEMPLATE_NAME = "template_medusa"
+const DEFAULT_DB_PREVIEW_PREFIX = "medusa_pr_"
+const DEFAULT_DB_PREVIEW_OWNER = "zane_operator"
+const DEFAULT_DB_PREVIEW_APP_USER_PREFIX = "medusa_pr_app_"
+const DEFAULT_DB_PREVIEW_DEV_ROLE = "medusa_dev"
+const DEFAULT_PROTECTED_DB_NAMES = [
+  "demo",
+  "postgres",
+  "template0",
+  "template1",
+  "template_medusa",
+]
+
+const IDENTIFIER_REGEX = /^[A-Za-z_][A-Za-z0-9_]*$/
+
+export interface AppConfig {
+  port: number
+  apiAuthToken: string
+  databaseUrl: string
+  defaultTemplateName: string
+  previewPrefix: string
+  previewOwner: string
+  previewAppUserPrefix: string
+  previewDevRole: string
+  previewAppPasswordSecret: string
+  protectedDbNames: Set<string>
+}
+
+type Environment = Record<string, string | undefined>
+
+function parsePort(rawValue: string | undefined, fallback: number, label: string): number {
+  if (!rawValue) {
+    return fallback
+  }
+
+  const parsed = Number.parseInt(rawValue, 10)
+
+  if (!Number.isInteger(parsed) || parsed <= 0 || parsed > 65_535) {
+    throw new Error(`${label} must be a valid TCP port (1-65535)`)
+  }
+
+  return parsed
+}
+
+function readRequiredEnv(env: Environment, name: string): string {
+  const value = env[name]?.trim()
+  if (!value) {
+    throw new Error(`${name} is required`)
+  }
+  return value
+}
+
+function assertSafeIdentifier(value: string, label: string): void {
+  if (!IDENTIFIER_REGEX.test(value)) {
+    throw new Error(`${label} must match ${IDENTIFIER_REGEX.source}`)
+  }
+}
+
+function parseProtectedDatabaseNames(rawValue: string | undefined): Set<string> {
+  const protectedNames = new Set<string>(DEFAULT_PROTECTED_DB_NAMES.map((name) => name.toLowerCase()))
+
+  if (!rawValue) {
+    return protectedNames
+  }
+
+  const extras = rawValue
+    .split(",")
+    .map((entry) => entry.trim())
+    .filter((entry) => entry.length > 0)
+
+  for (const entry of extras) {
+    assertSafeIdentifier(entry, "DB_PROTECTED_NAMES entry")
+    protectedNames.add(entry.toLowerCase())
+  }
+
+  return protectedNames
+}
+
+function buildPostgresConnectionUrl(env: Environment): string {
+  const host = readRequiredEnv(env, "PGHOST")
+  const port = parsePort(env.PGPORT, DEFAULT_PG_PORT, "PGPORT")
+  const user = readRequiredEnv(env, "PGUSER")
+  const password = readRequiredEnv(env, "PGPASSWORD")
+  const database = env.PGDATABASE?.trim() || DEFAULT_PG_DATABASE
+  const sslMode = env.PGSSLMODE?.trim() || DEFAULT_PG_SSL_MODE
+
+  const url = new URL("postgresql://placeholder")
+  url.hostname = host
+  url.port = String(port)
+  url.username = user
+  url.password = password
+  url.pathname = `/${database}`
+  url.searchParams.set("sslmode", sslMode)
+
+  return url.toString()
+}
+
+export function loadConfig(env: Environment = process.env): AppConfig {
+  const previewPrefix = env.DB_PREVIEW_PREFIX?.trim() || DEFAULT_DB_PREVIEW_PREFIX
+  const defaultTemplateName = env.DB_TEMPLATE_NAME?.trim() || DEFAULT_DB_TEMPLATE_NAME
+  const previewOwner = env.DB_PREVIEW_OWNER?.trim() || DEFAULT_DB_PREVIEW_OWNER
+  const previewAppUserPrefix = env.DB_PREVIEW_APP_USER_PREFIX?.trim() || DEFAULT_DB_PREVIEW_APP_USER_PREFIX
+  const previewDevRole = env.DB_PREVIEW_DEV_ROLE?.trim() || DEFAULT_DB_PREVIEW_DEV_ROLE
+  const apiAuthToken = readRequiredEnv(env, "API_AUTH_TOKEN")
+  const previewAppPasswordSecret = env.DB_PREVIEW_APP_PASSWORD_SECRET?.trim() || apiAuthToken
+
+  assertSafeIdentifier(previewPrefix, "DB_PREVIEW_PREFIX")
+  assertSafeIdentifier(defaultTemplateName, "DB_TEMPLATE_NAME")
+  assertSafeIdentifier(previewOwner, "DB_PREVIEW_OWNER")
+  assertSafeIdentifier(previewAppUserPrefix, "DB_PREVIEW_APP_USER_PREFIX")
+  assertSafeIdentifier(previewDevRole, "DB_PREVIEW_DEV_ROLE")
+
+  return {
+    port: parsePort(env.PORT, DEFAULT_PORT, "PORT"),
+    apiAuthToken,
+    databaseUrl: buildPostgresConnectionUrl(env),
+    defaultTemplateName,
+    previewPrefix,
+    previewOwner,
+    previewAppUserPrefix,
+    previewDevRole,
+    previewAppPasswordSecret,
+    protectedDbNames: parseProtectedDatabaseNames(env.DB_PROTECTED_NAMES),
+  }
+}
diff --git c/apps/zane-operator/src/db.ts i/apps/zane-operator/src/db.ts
new file mode 100644
index 00000000..846ea734
--- /dev/null
+++ i/apps/zane-operator/src/db.ts
@@ -0,0 +1,365 @@
+import { createHmac } from "node:crypto"
+import { SQL } from "bun"
+
+import type { AppConfig } from "./config"
+
+const IDENTIFIER_REGEX = /^[A-Za-z_][A-Za-z0-9_]*$/
+const MAX_IDENTIFIER_LENGTH = 63
+
+export class BadRequestError extends Error {
+  constructor(message: string) {
+    super(message)
+    this.name = "BadRequestError"
+  }
+}
+
+function assertSafeIdentifier(value: string, label: string): void {
+  if (!IDENTIFIER_REGEX.test(value)) {
+    throw new BadRequestError(`${label} must match ${IDENTIFIER_REGEX.source}`)
+  }
+
+  if (value.length > MAX_IDENTIFIER_LENGTH) {
+    throw new BadRequestError(`${label} must be at most ${MAX_IDENTIFIER_LENGTH} characters`)
+  }
+}
+
+function quoteIdentifier(identifier: string): string {
+  assertSafeIdentifier(identifier, "identifier")
+  return `"${identifier}"`
+}
+
+function quoteLiteral(value: string): string {
+  return `'${value.replaceAll("'", "''")}'`
+}
+
+export function parsePrNumber(value: unknown, label = "pr_number"): number {
+  const parsed =
+    typeof value === "number"
+      ? value
+      : typeof value === "string" && /^\d+$/.test(value)
+        ? Number.parseInt(value, 10)
+        : Number.NaN
+
+  if (!Number.isSafeInteger(parsed) || parsed <= 0) {
+    throw new BadRequestError(`${label} must be a positive integer`)
+  }
+
+  return parsed
+}
+
+function normalizeIdentifier(value: string, label: string): string {
+  const normalized = value.trim()
+
+  if (!normalized) {
+    throw new BadRequestError(`${label} cannot be empty`)
+  }
+
+  assertSafeIdentifier(normalized, label)
+  return normalized
+}
+
+export function createDbClient(config: AppConfig): Bun.SQL {
+  return new SQL({
+    url: config.databaseUrl,
+    max: 10,
+    idleTimeout: 15,
+    connectionTimeout: 10,
+  })
+}
+
+function buildPreviewDatabaseName(previewPrefix: string, prNumber: number): string {
+  const dbName = `${previewPrefix}${prNumber}`
+  assertSafeIdentifier(dbName, "derived database name")
+  return dbName
+}
+
+function buildPreviewAppRoleName(previewAppUserPrefix: string, prNumber: number): string {
+  const roleName = `${previewAppUserPrefix}${prNumber}`
+  assertSafeIdentifier(roleName, "derived app role name")
+  return roleName
+}
+
+function derivePreviewAppPassword(secret: string, dbName: string, roleName: string): string {
+  const digest = createHmac("sha256", secret).update(`${dbName}:${roleName}`).digest("base64url")
+  return `za_${digest.slice(0, 48)}`
+}
+
+function assertSafeTargetDatabaseName(dbName: string, config: AppConfig): void {
+  if (!dbName.startsWith(config.previewPrefix)) {
+    throw new BadRequestError("refusing operation outside preview database prefix")
+  }
+
+  if (config.protectedDbNames.has(dbName.toLowerCase())) {
+    throw new BadRequestError("refusing operation on protected database name")
+  }
+}
+
+async function databaseExists(sql: Bun.SQL, databaseName: string): Promise<boolean> {
+  const rows = await sql<{ exists: boolean }[]>`
+    SELECT EXISTS(
+      SELECT 1
+      FROM pg_database
+      WHERE datname = ${databaseName}
+    ) AS "exists"
+  `
+
+  return rows[0]?.exists === true
+}
+
+async function roleExists(sql: Bun.SQL, roleName: string): Promise<boolean> {
+  const rows = await sql<{ exists: boolean }[]>`
+    SELECT EXISTS(
+      SELECT 1
+      FROM pg_roles
+      WHERE rolname = ${roleName}
+    ) AS "exists"
+  `
+
+  return rows[0]?.exists === true
+}
+
+function buildDatabaseUrl(databaseUrl: string, databaseName: string): string {
+  const parsedUrl = new URL(databaseUrl)
+  parsedUrl.pathname = `/${databaseName}`
+  return parsedUrl.toString()
+}
+
+async function withDatabaseClient<T>(
+  config: AppConfig,
+  databaseName: string,
+  operation: (databaseSql: Bun.SQL) => Promise<T>,
+): Promise<T> {
+  const databaseSql = new SQL({
+    url: buildDatabaseUrl(config.databaseUrl, databaseName),
+    max: 4,
+    idleTimeout: 10,
+    connectionTimeout: 10,
+  })
+
+  try {
+    await databaseSql.connect()
+    return await operation(databaseSql)
+  } finally {
+    await databaseSql.close({ timeout: 5 })
+  }
+}
+
+async function withAdvisoryLock<T>(
+  sql: Bun.SQL,
+  lockKey: string,
+  operation: (lockedSql: Bun.ReservedSQL) => Promise<T>,
+): Promise<T> {
+  const reservedSql = await sql.reserve()
+  let lockAcquired = false
+
+  try {
+    await reservedSql`SELECT pg_advisory_lock(hashtext(${lockKey}))`
+    lockAcquired = true
+    return await operation(reservedSql)
+  } finally {
+    try {
+      if (lockAcquired) {
+        await reservedSql`SELECT pg_advisory_unlock(hashtext(${lockKey}))`
+      }
+    } finally {
+      reservedSql.release()
+    }
+  }
+}
+
+async function ensurePreviewAppRole(
+  sql: Bun.SQL,
+  previewOwner: string,
+  appRoleName: string,
+  appPassword: string,
+): Promise<void> {
+  const exists = await roleExists(sql, appRoleName)
+  if (!exists) {
+    await sql.unsafe(`CREATE ROLE ${quoteIdentifier(appRoleName)} LOGIN;`)
+  }
+
+  await sql.unsafe(
+    `ALTER ROLE ${quoteIdentifier(appRoleName)} WITH LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOBYPASSRLS INHERIT PASSWORD ${quoteLiteral(appPassword)};`,
+  )
+
+  // Allow preview owner to manage default privileges for app role objects.
+  if (previewOwner !== appRoleName) {
+    await sql.unsafe(`GRANT ${quoteIdentifier(appRoleName)} TO ${quoteIdentifier(previewOwner)};`)
+  }
+}
+
+async function syncPreviewDatabaseGrants(
+  sql: Bun.SQL,
+  config: AppConfig,
+  dbName: string,
+  appRoleName: string,
+): Promise<void> {
+  if (!(await roleExists(sql, config.previewOwner))) {
+    throw new BadRequestError(`configured preview owner role "${config.previewOwner}" does not exist`)
+  }
+
+  const devRole = config.previewDevRole
+
+  if (!(await roleExists(sql, devRole))) {
+    throw new BadRequestError(`configured dev role "${devRole}" does not exist`)
+  }
+
+  await sql.unsafe(`GRANT CONNECT, TEMP ON DATABASE ${quoteIdentifier(dbName)} TO ${quoteIdentifier(appRoleName)};`)
+  await sql.unsafe(`GRANT CONNECT, TEMP ON DATABASE ${quoteIdentifier(dbName)} TO ${quoteIdentifier(devRole)};`)
+
+  await withDatabaseClient(config, dbName, async (dbSql) => {
+    await dbSql.unsafe(`GRANT USAGE, CREATE ON SCHEMA public TO ${quoteIdentifier(appRoleName)};`)
+    await dbSql.unsafe(`GRANT USAGE, CREATE ON SCHEMA public TO ${quoteIdentifier(devRole)};`)
+
+    await dbSql.unsafe(
+      `GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO ${quoteIdentifier(appRoleName)}, ${quoteIdentifier(devRole)};`,
+    )
+    await dbSql.unsafe(
+      `GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public TO ${quoteIdentifier(appRoleName)}, ${quoteIdentifier(devRole)};`,
+    )
+    await dbSql.unsafe(
+      `GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA public TO ${quoteIdentifier(appRoleName)}, ${quoteIdentifier(devRole)};`,
+    )
+
+    // Ensure template-cloned objects owned by the executing role become writable for the preview app role.
+    await dbSql.unsafe(`REASSIGN OWNED BY CURRENT_USER TO ${quoteIdentifier(appRoleName)};`)
+    await dbSql.unsafe(`ALTER SCHEMA public OWNER TO ${quoteIdentifier(appRoleName)};`)
+
+    await dbSql.unsafe(
+      `ALTER DEFAULT PRIVILEGES FOR ROLE ${quoteIdentifier(appRoleName)} IN SCHEMA public GRANT ALL PRIVILEGES ON TABLES TO ${quoteIdentifier(appRoleName)}, ${quoteIdentifier(devRole)};`,
+    )
+    await dbSql.unsafe(
+      `ALTER DEFAULT PRIVILEGES FOR ROLE ${quoteIdentifier(appRoleName)} IN SCHEMA public GRANT ALL PRIVILEGES ON SEQUENCES TO ${quoteIdentifier(appRoleName)}, ${quoteIdentifier(devRole)};`,
+    )
+    await dbSql.unsafe(
+      `ALTER DEFAULT PRIVILEGES FOR ROLE ${quoteIdentifier(appRoleName)} IN SCHEMA public GRANT EXECUTE ON FUNCTIONS TO ${quoteIdentifier(appRoleName)}, ${quoteIdentifier(devRole)};`,
+    )
+  })
+}
+
+interface EnsurePreviewDatabaseParams {
+  prNumber: number
+  templateDatabase: string
+  owner: string
+}
+
+export async function ensurePreviewDatabase(
+  sql: Bun.SQL,
+  config: AppConfig,
+  params: EnsurePreviewDatabaseParams,
+): Promise<{ dbName: string; created: boolean; appUser: string; appPassword: string }> {
+  const dbName = buildPreviewDatabaseName(config.previewPrefix, params.prNumber)
+  const templateDatabase = normalizeIdentifier(params.templateDatabase, "template_db")
+  const owner = normalizeIdentifier(params.owner, "owner")
+  const appUser = buildPreviewAppRoleName(config.previewAppUserPrefix, params.prNumber)
+  const appPassword = derivePreviewAppPassword(config.previewAppPasswordSecret, dbName, appUser)
+
+  assertSafeTargetDatabaseName(dbName, config)
+
+  return await withAdvisoryLock(sql, dbName, async (lockedSql) => {
+    await ensurePreviewAppRole(lockedSql, config.previewOwner, appUser, appPassword)
+
+    const alreadyExists = await databaseExists(lockedSql, dbName)
+    if (!alreadyExists) {
+      const templateExists = await databaseExists(lockedSql, templateDatabase)
+      if (!templateExists) {
+        throw new BadRequestError(`template database "${templateDatabase}" does not exist`)
+      }
+
+      await lockedSql.unsafe(
+        `CREATE DATABASE ${quoteIdentifier(dbName)} WITH TEMPLATE ${quoteIdentifier(templateDatabase)} OWNER ${quoteIdentifier(owner)};`,
+      )
+    }
+
+    await syncPreviewDatabaseGrants(lockedSql, config, dbName, appUser)
+
+    return { dbName, created: !alreadyExists, appUser, appPassword }
+  })
+}
+
+async function countActiveDatabaseConnections(sql: Bun.SQL, databaseName: string): Promise<number> {
+  const rows = await sql<{ active_connections: number }[]>`
+    SELECT COUNT(*)::int AS "active_connections"
+    FROM pg_stat_activity
+    WHERE datname = ${databaseName}
+      AND pid <> pg_backend_pid()
+  `
+
+  return rows[0]?.active_connections ?? 0
+}
+
+async function dropDatabase(sql: Bun.SQL, databaseName: string): Promise<void> {
+  const quotedDbName = quoteIdentifier(databaseName)
+
+  try {
+    await sql.unsafe(`DROP DATABASE IF EXISTS ${quotedDbName} WITH (FORCE);`)
+    return
+  } catch (error: unknown) {
+    const message = error instanceof Error ? error.message : String(error)
+    const normalizedMessage = message.toLowerCase()
+
+    if (normalizedMessage.includes("syntax error at or near \"with\"")) {
+      await sql.unsafe(`DROP DATABASE IF EXISTS ${quotedDbName};`)
+      return
+    }
+
+    if (normalizedMessage.includes("permission denied to terminate process")) {
+      throw new BadRequestError(
+        "database role cannot terminate active sessions required for DROP DATABASE ... WITH (FORCE). Grant pg_signal_backend to the operator role (e.g. GRANT pg_signal_backend TO zane_operator).",
+      )
+    }
+
+    if (normalizedMessage.includes("is being accessed by other users")) {
+      throw new BadRequestError(
+        "database still has active connections and could not be dropped. Ensure teardown role can terminate backends for the target preview database.",
+      )
+    }
+
+    throw error
+  }
+}
+
+async function dropPreviewAppRole(sql: Bun.SQL, roleName: string): Promise<boolean> {
+  if (!(await roleExists(sql, roleName))) {
+    return false
+  }
+
+  try {
+    await sql.unsafe(`DROP ROLE IF EXISTS ${quoteIdentifier(roleName)};`)
+    return true
+  } catch (error: unknown) {
+    const message = error instanceof Error ? error.message : String(error)
+    const normalizedMessage = message.toLowerCase()
+
+    if (normalizedMessage.includes("cannot be dropped because some objects depend on it")) {
+      throw new BadRequestError(`preview app role "${roleName}" still owns objects and cannot be dropped`)
+    }
+
+    throw error
+  }
+}
+
+export async function teardownPreviewDatabase(
+  sql: Bun.SQL,
+  config: AppConfig,
+  prNumber: number,
+): Promise<{ dbName: string; deleted: boolean; terminatedConnections: number; appUser: string; roleDeleted: boolean }> {
+  const dbName = buildPreviewDatabaseName(config.previewPrefix, prNumber)
+  const appUser = buildPreviewAppRoleName(config.previewAppUserPrefix, prNumber)
+  assertSafeTargetDatabaseName(dbName, config)
+
+  return await withAdvisoryLock(sql, dbName, async (lockedSql) => {
+    let deleted = false
+    let terminatedConnections = 0
+
+    if (await databaseExists(lockedSql, dbName)) {
+      terminatedConnections = await countActiveDatabaseConnections(lockedSql, dbName)
+      await dropDatabase(lockedSql, dbName)
+      deleted = true
+    }
+
+    const roleDeleted = await dropPreviewAppRole(lockedSql, appUser)
+
+    return { dbName, deleted, terminatedConnections, appUser, roleDeleted }
+  })
+}
diff --git c/apps/zane-operator/src/handlers/ensure-preview-db.ts i/apps/zane-operator/src/handlers/ensure-preview-db.ts
new file mode 100644
index 00000000..d79e02bd
--- /dev/null
+++ i/apps/zane-operator/src/handlers/ensure-preview-db.ts
@@ -0,0 +1,75 @@
+import type { AppConfig } from "../config"
+import { BadRequestError, ensurePreviewDatabase, parsePrNumber } from "../db"
+import { jsonResponse, mapHandlerError } from "../http"
+
+interface EnsurePreviewDbDeps {
+  config: AppConfig
+  sql: Bun.SQL
+}
+
+interface EnsurePreviewDbPayload {
+  pr_number: unknown
+}
+
+function parsePayload(rawPayload: unknown): EnsurePreviewDbPayload {
+  if (!rawPayload || typeof rawPayload !== "object" || Array.isArray(rawPayload)) {
+    throw new BadRequestError("request body must be a JSON object")
+  }
+
+  const payload = rawPayload as Record<string, unknown>
+  if (!("pr_number" in payload)) {
+    throw new BadRequestError("request body is missing pr_number")
+  }
+
+  if ("template_db" in payload) {
+    throw new BadRequestError("template_db override is disabled")
+  }
+
+  if ("owner" in payload) {
+    throw new BadRequestError("owner override is disabled")
+  }
+
+  return {
+    pr_number: payload.pr_number,
+  }
+}
+
+export async function handleEnsurePreviewDb(request: Request, deps: EnsurePreviewDbDeps): Promise<Response> {
+  try {
+    const rawBody = await request.json().catch(() => {
+      throw new BadRequestError("request body must be valid JSON")
+    })
+
+    const payload = parsePayload(rawBody)
+    const prNumber = parsePrNumber(payload.pr_number)
+    const templateDatabase = deps.config.defaultTemplateName
+    const owner = deps.config.previewOwner
+
+    const result = await ensurePreviewDatabase(deps.sql, deps.config, {
+      prNumber,
+      templateDatabase,
+      owner,
+    })
+
+    console.info(
+      JSON.stringify({
+        event: "preview-db.ensure",
+        pr_number: prNumber,
+        db_name: result.dbName,
+        created: result.created,
+        app_user: result.appUser,
+        template_db: templateDatabase,
+        owner,
+      }),
+    )
+
+    return jsonResponse(200, {
+      db_name: result.dbName,
+      created: result.created,
+      app_user: result.appUser,
+      app_password: result.appPassword,
+    })
+  } catch (error: unknown) {
+    return mapHandlerError(error, "ensure-preview-db")
+  }
+}
diff --git c/apps/zane-operator/src/handlers/health.ts i/apps/zane-operator/src/handlers/health.ts
new file mode 100644
index 00000000..1a824f5d
--- /dev/null
+++ i/apps/zane-operator/src/handlers/health.ts
@@ -0,0 +1,5 @@
+import { jsonResponse } from "../http"
+
+export function handleHealth(): Response {
+  return jsonResponse(200, { ok: true })
+}
diff --git c/apps/zane-operator/src/handlers/teardown-preview-db.ts i/apps/zane-operator/src/handlers/teardown-preview-db.ts
new file mode 100644
index 00000000..1d0003a6
--- /dev/null
+++ i/apps/zane-operator/src/handlers/teardown-preview-db.ts
@@ -0,0 +1,39 @@
+import type { AppConfig } from "../config"
+import { parsePrNumber, teardownPreviewDatabase } from "../db"
+import { jsonResponse, mapHandlerError } from "../http"
+
+interface TeardownPreviewDbDeps {
+  config: AppConfig
+  sql: Bun.SQL
+}
+
+export async function handleTeardownPreviewDb(
+  prNumberParam: string,
+  deps: TeardownPreviewDbDeps,
+): Promise<Response> {
+  try {
+    const prNumber = parsePrNumber(prNumberParam, "pr_number path parameter")
+    const result = await teardownPreviewDatabase(deps.sql, deps.config, prNumber)
+
+    console.info(
+      JSON.stringify({
+        event: "preview-db.teardown",
+        pr_number: prNumber,
+        db_name: result.dbName,
+        deleted: result.deleted,
+        app_user: result.appUser,
+        role_deleted: result.roleDeleted,
+        terminated_connections: result.terminatedConnections,
+      }),
+    )
+
+    return jsonResponse(200, {
+      db_name: result.dbName,
+      deleted: result.deleted,
+      app_user: result.appUser,
+      role_deleted: result.roleDeleted,
+    })
+  } catch (error: unknown) {
+    return mapHandlerError(error, "teardown-preview-db")
+  }
+}
diff --git c/apps/zane-operator/src/http.ts i/apps/zane-operator/src/http.ts
new file mode 100644
index 00000000..bdcd8daf
--- /dev/null
+++ i/apps/zane-operator/src/http.ts
@@ -0,0 +1,39 @@
+import { BadRequestError } from "./db"
+
+interface ErrorBody {
+  error: string
+  message: string
+}
+
+export function jsonResponse(status: number, payload: unknown): Response {
+  return Response.json(payload, { status })
+}
+
+export function jsonError(status: number, error: string, message: string): Response {
+  const body: ErrorBody = { error, message }
+  return jsonResponse(status, body)
+}
+
+export function mapHandlerError(error: unknown, context: string): Response {
+  if (error instanceof BadRequestError) {
+    console.warn(
+      JSON.stringify({
+        event: "handler.bad_request",
+        context,
+        message: error.message,
+      }),
+    )
+    return jsonError(400, "bad_request", error.message)
+  }
+
+  const message = error instanceof Error ? error.message : String(error)
+  console.error(
+    JSON.stringify({
+      event: "handler.error",
+      context,
+      message,
+    }),
+  )
+
+  return jsonError(500, "internal_error", "Internal server error")
+}
diff --git c/apps/zane-operator/src/main.ts i/apps/zane-operator/src/main.ts
new file mode 100644
index 00000000..a6b3c170
--- /dev/null
+++ i/apps/zane-operator/src/main.ts
@@ -0,0 +1,89 @@
+import { enforceBearerToken } from "./auth"
+import { loadConfig } from "./config"
+import { createDbClient } from "./db"
+import { handleEnsurePreviewDb } from "./handlers/ensure-preview-db"
+import { handleHealth } from "./handlers/health"
+import { handleTeardownPreviewDb } from "./handlers/teardown-preview-db"
+import { jsonError, jsonResponse } from "./http"
+
+const config = loadConfig()
+const sql = createDbClient(config)
+
+await sql.connect()
+
+const server = Bun.serve({
+  port: config.port,
+  idleTimeout: 30,
+  fetch: async (request) => {
+    const url = new URL(request.url)
+
+    if (request.method === "GET" && url.pathname === "/healthz") {
+      return handleHealth()
+    }
+
+    if (request.method === "POST" && url.pathname === "/v1/preview-db/ensure") {
+      const authResponse = enforceBearerToken(request, config.apiAuthToken)
+      if (authResponse) {
+        return authResponse
+      }
+
+      return await handleEnsurePreviewDb(request, { config, sql })
+    }
+
+    const teardownMatch = /^\/v1\/preview-db\/([^/]+)\/?$/.exec(url.pathname)
+    if (request.method === "DELETE" && teardownMatch?.[1]) {
+      const authResponse = enforceBearerToken(request, config.apiAuthToken)
+      if (authResponse) {
+        return authResponse
+      }
+
+      return await handleTeardownPreviewDb(teardownMatch[1], { config, sql })
+    }
+
+    if (url.pathname.startsWith("/v1/preview-db/")) {
+      return jsonError(405, "method_not_allowed", "Method not allowed for this endpoint")
+    }
+
+    return jsonResponse(404, {
+      error: "not_found",
+      message: "Route not found",
+    })
+  },
+  error: (error) => {
+    console.error(
+      JSON.stringify({
+        event: "server.error",
+        message: error.message,
+      }),
+    )
+    return jsonError(500, "internal_error", "Internal server error")
+  },
+})
+
+console.info(
+  JSON.stringify({
+    event: "server.started",
+    port: config.port,
+  }),
+)
+
+let shuttingDown = false
+
+const handleShutdown = async (signal: string): Promise<void> => {
+  if (shuttingDown) {
+    return
+  }
+
+  shuttingDown = true
+  console.info(JSON.stringify({ event: "server.shutdown", signal }))
+
+  server.stop(true)
+  await sql.close({ timeout: 5 })
+  process.exit(0)
+}
+
+for (const signal of ["SIGINT", "SIGTERM"] as const) {
+  process.on(signal, () => {
+    void handleShutdown(signal)
+  })
+}
diff --git c/apps/zane-operator/tsconfig.json i/apps/zane-operator/tsconfig.json
new file mode 100644
index 00000000..48ed6e23
--- /dev/null
+++ i/apps/zane-operator/tsconfig.json
@@ -0,0 +1,14 @@
+{
+  "extends": "../../tsconfig.base.json",
+  "compilerOptions": {
+    "rootDir": "src",
+    "outDir": "dist",
+    "module": "ESNext",
+    "moduleResolution": "bundler",
+    "types": ["bun"],
+    "composite": false,
+    "incremental": false,
+    "noEmit": true
+  },
+  "include": ["src/**/*.ts"]
+}
diff --git c/docker-compose.yaml i/docker-compose.yaml
index 2ffb61c0..fab0d1da 100644
--- c/docker-compose.yaml
+++ i/docker-compose.yaml
@@ -150,7 +150,8 @@ services:
     ports:
       - "5432:5432"
     volumes:
-      - ./.docker_data/db18:/var/lib/postgresql
+      - ${DC_POSTGRES_VOLUME_DATA:-./.docker_data/db}:/var/lib/postgresql
+      - ./docker/development/postgres/initdb:/docker-entrypoint-initdb.d:ro
     networks:
       - internal
     healthcheck:
@@ -164,6 +165,13 @@ services:
       POSTGRES_USER: root
       POSTGRES_DB: medusa
       PGDATA: /var/lib/postgresql/18/docker
+      ZANE_OPERATOR_DB_USER: ${DC_ZANE_OPERATOR_DB_USER:-zane_operator}
+      ZANE_OPERATOR_DB_PASSWORD: ${DC_ZANE_OPERATOR_DB_PASSWORD:-zane_operator_change_me}
+      MEDUSA_APP_DB_USER: ${DC_MEDUSA_APP_DB_USER:-medusa_app}
+      MEDUSA_APP_DB_PASSWORD: ${DC_MEDUSA_APP_DB_PASSWORD:-medusa_app_change_me}
+      MEDUSA_DEV_DB_USER: ${DC_MEDUSA_DEV_DB_USER:-medusa_dev}
+      MEDUSA_DEV_DB_PASSWORD: ${DC_MEDUSA_DEV_DB_PASSWORD:-medusa_dev_change_me}
+      MEDUSA_APP_DB_NAME: ${DC_MEDUSA_APP_DB_NAME:-medusa}
   engine-db:
     image: mariadb:latest
     restart: no
diff --git c/docker/development/postgres/initdb/01-zane-role-bootstrap.sh i/docker/development/postgres/initdb/01-zane-role-bootstrap.sh
new file mode 100644
index 00000000..3e968dbc
--- /dev/null
+++ i/docker/development/postgres/initdb/01-zane-role-bootstrap.sh
@@ -0,0 +1,165 @@
+#!/usr/bin/env sh
+set -eu
+
+log() {
+  printf '[postgres-init] %s\n' "$1"
+}
+
+is_identifier() {
+  printf '%s' "$1" | grep -Eq '^[A-Za-z_][A-Za-z0-9_]*$'
+}
+
+require_identifier() {
+  variable_name="$1"
+  variable_value="$2"
+  if ! is_identifier "$variable_value"; then
+    printf '[postgres-init] invalid identifier for %s: %s\n' "$variable_name" "$variable_value" >&2
+    exit 1
+  fi
+}
+
+: "${POSTGRES_USER:?POSTGRES_USER is required}"
+: "${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}"
+: "${POSTGRES_DB:=medusa}"
+
+: "${ZANE_OPERATOR_DB_USER:=zane_operator}"
+: "${ZANE_OPERATOR_DB_PASSWORD:=zane_operator_change_me}"
+: "${MEDUSA_APP_DB_USER:=medusa_app}"
+: "${MEDUSA_APP_DB_PASSWORD:=medusa_app_change_me}"
+: "${MEDUSA_DEV_DB_USER:=medusa_dev}"
+: "${MEDUSA_DEV_DB_PASSWORD:=medusa_dev_change_me}"
+: "${MEDUSA_APP_DB_NAME:=${POSTGRES_DB}}"
+
+require_identifier "POSTGRES_USER" "$POSTGRES_USER"
+require_identifier "POSTGRES_DB" "$POSTGRES_DB"
+require_identifier "ZANE_OPERATOR_DB_USER" "$ZANE_OPERATOR_DB_USER"
+require_identifier "MEDUSA_APP_DB_USER" "$MEDUSA_APP_DB_USER"
+require_identifier "MEDUSA_DEV_DB_USER" "$MEDUSA_DEV_DB_USER"
+require_identifier "MEDUSA_APP_DB_NAME" "$MEDUSA_APP_DB_NAME"
+
+export PGPASSWORD="$POSTGRES_PASSWORD"
+
+log "Bootstrapping roles and grants (zane-operator/app/dev)"
+psql --username "$POSTGRES_USER" --dbname postgres \
+  -v ON_ERROR_STOP=1 \
+  -v postgres_user="$POSTGRES_USER" \
+  -v app_db="$MEDUSA_APP_DB_NAME" \
+  -v zane_user="$ZANE_OPERATOR_DB_USER" \
+  -v zane_pass="$ZANE_OPERATOR_DB_PASSWORD" \
+  -v app_user="$MEDUSA_APP_DB_USER" \
+  -v app_pass="$MEDUSA_APP_DB_PASSWORD" \
+  -v dev_user="$MEDUSA_DEV_DB_USER" \
+  -v dev_pass="$MEDUSA_DEV_DB_PASSWORD" <<'SQL'
+DO $do$
+BEGIN
+  IF NOT EXISTS (SELECT 1 FROM pg_catalog.pg_roles WHERE rolname = :'zane_user') THEN
+    EXECUTE format('CREATE ROLE %I LOGIN', :'zane_user');
+  END IF;
+
+  EXECUTE format(
+    'ALTER ROLE %I WITH LOGIN CREATEDB NOSUPERUSER NOCREATEROLE NOBYPASSRLS INHERIT PASSWORD %L',
+    :'zane_user',
+    :'zane_pass'
+  );
+  EXECUTE format('GRANT pg_signal_backend TO %I', :'zane_user');
+END
+$do$;
+
+DO $do$
+BEGIN
+  IF NOT EXISTS (SELECT 1 FROM pg_catalog.pg_roles WHERE rolname = :'app_user') THEN
+    EXECUTE format('CREATE ROLE %I LOGIN', :'app_user');
+  END IF;
+
+  EXECUTE format(
+    'ALTER ROLE %I WITH LOGIN NOCREATEDB NOSUPERUSER NOCREATEROLE NOBYPASSRLS INHERIT PASSWORD %L',
+    :'app_user',
+    :'app_pass'
+  );
+END
+$do$;
+
+DO $do$
+BEGIN
+  IF NOT EXISTS (SELECT 1 FROM pg_catalog.pg_roles WHERE rolname = :'dev_user') THEN
+    EXECUTE format('CREATE ROLE %I LOGIN', :'dev_user');
+  END IF;
+
+  EXECUTE format(
+    'ALTER ROLE %I WITH LOGIN NOCREATEDB NOSUPERUSER NOCREATEROLE NOBYPASSRLS INHERIT PASSWORD %L',
+    :'dev_user',
+    :'dev_pass'
+  );
+
+  EXECUTE format('GRANT pg_read_all_data TO %I', :'dev_user');
+  EXECUTE format('GRANT pg_write_all_data TO %I', :'dev_user');
+  EXECUTE format('GRANT pg_monitor TO %I', :'dev_user');
+END
+$do$;
+
+DO $do$
+DECLARE
+  db_record RECORD;
+BEGIN
+  FOR db_record IN
+    SELECT datname
+    FROM pg_database
+    WHERE datistemplate = false
+  LOOP
+    EXECUTE format('GRANT CONNECT, TEMP ON DATABASE %I TO %I', db_record.datname, :'zane_user');
+    EXECUTE format('GRANT CONNECT ON DATABASE %I TO %I', db_record.datname, :'dev_user');
+  END LOOP;
+END
+$do$;
+
+DO $do$
+BEGIN
+  IF EXISTS (SELECT 1 FROM pg_database WHERE datname = :'app_db') THEN
+    EXECUTE format('ALTER DATABASE %I OWNER TO %I', :'app_db', :'app_user');
+    EXECUTE format('REVOKE CONNECT ON DATABASE %I FROM PUBLIC', :'app_db');
+    EXECUTE format('GRANT CONNECT, TEMP ON DATABASE %I TO %I', :'app_db', :'app_user');
+    EXECUTE format('GRANT CONNECT, TEMP ON DATABASE %I TO %I', :'app_db', :'zane_user');
+    EXECUTE format('GRANT ALL PRIVILEGES ON DATABASE %I TO %I', :'app_db', :'dev_user');
+  END IF;
+END
+$do$;
+SQL
+
+log "Applying schema-level grants on database: ${MEDUSA_APP_DB_NAME}"
+app_db_exists="$(psql --username "$POSTGRES_USER" --dbname postgres -tA -c "SELECT 1 FROM pg_database WHERE datname = '${MEDUSA_APP_DB_NAME}'")"
+if [ "$app_db_exists" = "1" ]; then
+  psql --username "$POSTGRES_USER" --dbname "$MEDUSA_APP_DB_NAME" \
+    -v ON_ERROR_STOP=1 \
+    -v postgres_user="$POSTGRES_USER" \
+    -v app_user="$MEDUSA_APP_DB_USER" \
+    -v dev_user="$MEDUSA_DEV_DB_USER" <<'SQL'
+  REVOKE CREATE ON SCHEMA public FROM PUBLIC;
+  GRANT USAGE, CREATE ON SCHEMA public TO :"app_user";
+  GRANT USAGE, CREATE ON SCHEMA public TO :"dev_user";
+
+  GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO :"app_user";
+  GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO :"dev_user";
+  GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public TO :"app_user";
+  GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public TO :"dev_user";
+  GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA public TO :"app_user";
+  GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA public TO :"dev_user";
+
+  ALTER DEFAULT PRIVILEGES FOR ROLE :"app_user" IN SCHEMA public
+    GRANT ALL PRIVILEGES ON TABLES TO :"app_user", :"dev_user";
+  ALTER DEFAULT PRIVILEGES FOR ROLE :"app_user" IN SCHEMA public
+    GRANT ALL PRIVILEGES ON SEQUENCES TO :"app_user", :"dev_user";
+  ALTER DEFAULT PRIVILEGES FOR ROLE :"app_user" IN SCHEMA public
+    GRANT EXECUTE ON FUNCTIONS TO :"app_user", :"dev_user";
+
+  ALTER DEFAULT PRIVILEGES FOR ROLE :"postgres_user" IN SCHEMA public
+    GRANT ALL PRIVILEGES ON TABLES TO :"app_user", :"dev_user";
+  ALTER DEFAULT PRIVILEGES FOR ROLE :"postgres_user" IN SCHEMA public
+    GRANT ALL PRIVILEGES ON SEQUENCES TO :"app_user", :"dev_user";
+  ALTER DEFAULT PRIVILEGES FOR ROLE :"postgres_user" IN SCHEMA public
+    GRANT EXECUTE ON FUNCTIONS TO :"app_user", :"dev_user";
+SQL
+else
+  log "Skipping schema grants because database ${MEDUSA_APP_DB_NAME} does not exist"
+fi
+
+log "Role bootstrap complete"
diff --git c/docker/development/zane-operator/Dockerfile i/docker/development/zane-operator/Dockerfile
new file mode 100644
index 00000000..7060ad63
--- /dev/null
+++ i/docker/development/zane-operator/Dockerfile
@@ -0,0 +1,32 @@
+FROM oven/bun:1.3.5 AS build
+
+WORKDIR /workspace
+
+COPY apps/zane-operator/src ./apps/zane-operator/src
+
+RUN bun build ./apps/zane-operator/src/main.ts --compile --outfile /out/zane-operator
+
+FROM debian:bookworm-slim AS runtime
+
+RUN apt-get update \
+  && apt-get install -y --no-install-recommends ca-certificates curl \
+  && rm -rf /var/lib/apt/lists/*
+
+WORKDIR /app
+
+COPY --from=build /out/zane-operator /app/zane-operator
+
+RUN useradd --system --uid 1001 zaneoperator \
+  && chown zaneoperator:zaneoperator /app/zane-operator \
+  && chmod +x /app/zane-operator
+
+USER zaneoperator
+
+ENV PORT=8080
+
+EXPOSE 8080
+
+HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
+  CMD curl -fsS "http://127.0.0.1:${PORT}/healthz" >/dev/null || exit 1
+
+ENTRYPOINT ["/app/zane-operator"]
diff --git c/scripts/apply-postgres-role-bootstrap.sh i/scripts/apply-postgres-role-bootstrap.sh
new file mode 100644
index 00000000..72553ed3
--- /dev/null
+++ i/scripts/apply-postgres-role-bootstrap.sh
@@ -0,0 +1,12 @@
+#!/usr/bin/env sh
+set -eu
+
+if ! command -v docker >/dev/null 2>&1; then
+  echo "docker is required" >&2
+  exit 1
+fi
+
+echo "Applying postgres role bootstrap to running medusa-db container..."
+docker compose exec -T medusa-db /docker-entrypoint-initdb.d/01-zane-role-bootstrap.sh
+
+echo "Done."
diff --git c/.env.docker i/.env.docker
index fa88cba2..083c281b 100644
--- c/.env.docker
+++ i/.env.docker
@@ -1,3 +1,6 @@
+# Shared runtime mode
+DC_NODE_ENV=development
+
 #Medusa N1 container
 DC_N1_MEDUSA_BACKEND_URL_INTERNAL=http://medusa-be:9000
 DC_N1_NEXT_PUBLIC_MEDUSA_BACKEND_URL=http://localhost:9000
@@ -16,7 +19,12 @@ DC_N1_MEDUSA_RESEND_FROM_EMAIL=onboarding@resend.dev
 #Medusa BE container
 DC_JWT_SECRET=supersecret
 DC_COOKIE_SECRET=supersecret
-DC_DATABASE_URL=postgresql://medusa_app:medusa_app_change_me@medusa-db:5432/medusa?sslmode=disable
+DC_POSTGRES_SUPERUSER=root
+DC_POSTGRES_SUPERUSER_PASSWORD=root
+DC_POSTGRES_PUBLIC_PORT=5432
+DC_POSTGRES_SSLMODE=disable
+DC_POSTGRES_VOLUME_DATA=./.docker_data/db
+# medusa-be DATABASE_URL is generated in docker-compose from app DB vars below
 DC_LEGACY_DATABASE_URL=mysql://root:1234@engine-db:3306/n1shop
 DC_ZANE_OPERATOR_DB_USER=zane_operator
 DC_ZANE_OPERATOR_DB_PASSWORD=zane_operator_change_me
@@ -25,6 +33,7 @@ DC_MEDUSA_APP_DB_PASSWORD=medusa_app_change_me
 DC_MEDUSA_DEV_DB_USER=medusa_dev
 DC_MEDUSA_DEV_DB_PASSWORD=medusa_dev_change_me
 DC_MEDUSA_APP_DB_NAME=medusa
+DC_MEDUSA_APP_DB_SCHEMA=medusa
 DC_MINIO_FILE_URL="http://medusa-minio:9004/medusa-bucket"
 DC_MINIO_REGION="us-east-1"
 DC_MINIO_BUCKET="medusa-bucket"
@@ -52,3 +61,16 @@ DC_PPL_ENVIRONMENT=testing

 #Meilisearch container
 DC_MEILI_MASTER_KEY=MEILI_MASTER_KEY_FOR_DEVELOPMENT_ONLY
+
+# zane-operator container
+DC_ZANE_OPERATOR_PUBLIC_PORT=8082
+DC_ZANE_OPERATOR_PORT=8080
+DC_ZANE_OPERATOR_API_AUTH_TOKEN=replace-with-strong-random-token
+DC_ZANE_OPERATOR_PGUSER=zane_operator
+DC_ZANE_OPERATOR_PGPASSWORD=replace-with-zane-operator-db-password
+DC_ZANE_OPERATOR_PGDATABASE=postgres
+DC_ZANE_OPERATOR_DB_TEMPLATE_NAME=template_medusa
+DC_ZANE_OPERATOR_DB_PREVIEW_PREFIX=medusa_pr_
+DC_ZANE_OPERATOR_DB_PREVIEW_APP_USER_PREFIX=medusa_pr_app_
+DC_ZANE_OPERATOR_DB_PREVIEW_APP_PASSWORD_SECRET=replace-with-different-strong-random-secret
+DC_ZANE_OPERATOR_DB_PROTECTED_NAMES=demo,postgres,template0,template1,template_medusa
diff --git c/README.md i/README.md
index 5fed5a83..b042db0d 100644
--- c/README.md
+++ i/README.md
@@ -27,11 +27,24 @@
     ```shell
     make dev
     ```
-    * Postgres role bootstrap (`zane_operator`, `medusa_app`, `medusa_dev`) runs automatically on first DB initialization via `docker/development/postgres/initdb/01-zane-role-bootstrap.sh`
+    * Postgres role bootstrap (`medusa_app`, `medusa_dev`) runs automatically on first DB initialization via `docker/development/postgres/initdb/01-zane-role-bootstrap.sh`
+    * Bootstrap no longer creates `zane_operator`; create/manage that role from `apps/zane-operator` onboarding instructions
     * If your Postgres volume already existed before this change, apply bootstrap manually once:
     ```shell
     ./scripts/apply-postgres-role-bootstrap.sh
     ```
+    * Medusa BE must use app credentials (`medusa_app`) in `DC_DATABASE_URL`, not superuser credentials
+
+### Manual live `.env` updates (not automated)
+
+When DB env wiring changes, apply these actions manually on the live `.env` file:
+
+1. Open live `.env`.
+2. Set medusa-be DB connection values to APP credentials (`medusa_app`-style account), not superuser credentials.
+3. Ensure DB host, port, and database match the compose service defaults used in your environment.
+4. Keep operator-only credentials separate from app credentials.
+5. Restart services that consume `.env`.
+6. Validate with one read and one write operation from medusa-be.

 4. <b>Migrate database</b> (if needed)
     * <i>(optional)</i> `medusa` schema needs to exist, which it should, unless it was manually dropped
diff --git c/apps/zane-operator/.env.example i/apps/zane-operator/.env.example
index dadb95e2..2316b41e 100644
--- c/apps/zane-operator/.env.example
+++ i/apps/zane-operator/.env.example
@@ -16,5 +16,6 @@ DB_PREVIEW_PREFIX=medusa_pr_
 DB_PREVIEW_OWNER=zane_operator
 DB_PREVIEW_APP_USER_PREFIX=medusa_pr_app_
 DB_PREVIEW_DEV_ROLE=medusa_dev
+DB_APP_SCHEMA=medusa
 DB_PREVIEW_APP_PASSWORD_SECRET=replace-with-strong-random-secret
 DB_PROTECTED_NAMES=demo,postgres,template0,template1,template_medusa
diff --git c/apps/zane-operator/README.md i/apps/zane-operator/README.md
index bb950928..77f3ed5d 100644
--- c/apps/zane-operator/README.md
+++ i/apps/zane-operator/README.md
@@ -2,6 +2,8 @@

 Internal Bun service for preview PostgreSQL database lifecycle operations.

+Minimum supported PostgreSQL version: `18`.
+
 ## Endpoints

 - `GET /healthz`
@@ -40,7 +42,10 @@ Teardown response includes role cleanup result:
   "db_name": "medusa_pr_123",
   "deleted": true,
   "app_user": "medusa_pr_app_123",
-  "role_deleted": true
+  "role_deleted": true,
+  "dev_grants_cleaned": true,
+  "noop": false,
+  "noop_reason": null
 }
 ```

@@ -62,7 +67,8 @@ Teardown response includes role cleanup result:
 - `DB_PREVIEW_OWNER` (default: `zane_operator`)
 - `DB_PREVIEW_APP_USER_PREFIX` (default: `medusa_pr_app_`)
 - `DB_PREVIEW_DEV_ROLE` (default: `medusa_dev`)
-- `DB_PREVIEW_APP_PASSWORD_SECRET` (defaults to `API_AUTH_TOKEN`, but set explicitly in production)
+- `DB_APP_SCHEMA` (default: `medusa`)
+- `DB_PREVIEW_APP_PASSWORD_SECRET` (required in production; defaults to `API_AUTH_TOKEN` in non-production)
 - `DB_PROTECTED_NAMES` (extra protected DB names, comma-separated)

 ## Onboarding
@@ -72,12 +78,21 @@ Teardown response includes role cleanup result:
 For local Docker Compose environments, role bootstrap is automated by:
 - `docker/development/postgres/initdb/01-zane-role-bootstrap.sh`

+Bootstrap creates/maintains `medusa_app` and `medusa_dev` only.
+Bootstrap does **not** create `zane_operator`.
+
 For existing Postgres volumes (already initialized before bootstrap script was added), apply once:

 ```bash
 ./scripts/apply-postgres-role-bootstrap.sh
 ```

+Idempotency verification for existing databases:
+
+```bash
+./scripts/apply-postgres-role-bootstrap.sh --verify-idempotent
+```
+
 Run as a PostgreSQL admin role:

 ```sql
@@ -93,6 +108,7 @@ GRANT pg_signal_backend TO zane_operator;
 - clone from template DB (`template_medusa`)

 `medusa_dev` (or your configured `DB_PREVIEW_DEV_ROLE`) must exist. `ensure` grants it connect+schema/table access on each preview DB.
+Preview app users are scoped to one schema only (configured by `DB_APP_SCHEMA`, default `medusa`).

 For `CREATE DATABASE ... WITH TEMPLATE ...`, the operator must be superuser or own the template DB.
 Preferred setup is ownership transfer of the template DB:
@@ -119,6 +135,7 @@ Required production values:
 - `DB_PREVIEW_OWNER=zane_operator`
 - `DB_PREVIEW_APP_USER_PREFIX=medusa_pr_app_`
 - `DB_PREVIEW_DEV_ROLE=medusa_dev`
+- `DB_APP_SCHEMA=medusa`
 - `DB_PREVIEW_APP_PASSWORD_SECRET=<long-random-secret>`

 ### 3. Smoke test before deployment
@@ -193,7 +210,8 @@ Required values in `.env` for this container:
 - `DB_PREVIEW_OWNER` (optional)
 - `DB_PREVIEW_APP_USER_PREFIX` (optional)
 - `DB_PREVIEW_DEV_ROLE` (optional)
-- `DB_PREVIEW_APP_PASSWORD_SECRET` (recommended)
+- `DB_APP_SCHEMA` (optional, default `medusa`)
+- `DB_PREVIEW_APP_PASSWORD_SECRET` (required in production)

 If `medusa-db` is in Docker Compose, set `PGHOST` in `.env` to the Compose service name (usually `medusa-db`) and run this container on the same Docker network.

@@ -214,7 +232,36 @@ Preview workflow template is present in:

 zane-operator calls are intentionally commented out for now. Uncomment them when you are ready to enable preview DB lifecycle from PR events.

-### 7. Manually refresh `template_medusa` from a different source DB
+### 7. Create or update dev role via CLI (no HTTP route)
+
+Command:
+
+```bash
+cd apps/zane-operator
+bun run create:dev-user -- --username medusa_dev --password 'replace-with-strong-password'
+```
+
+Optional flag:
+- `--no-grant-connect-all-dbs` skips cross-database `CONNECT` and schema/object grant sync.
+- `--allow-prod-broad-grants` allows broad grants when `NODE_ENV=production` (default behavior blocks this)
+
+Behavior:
+- creates the role when missing
+- always enforces role attributes: `NOSUPERUSER NOCREATEDB NOCREATEROLE NOBYPASSRLS INHERIT LOGIN`
+- grants explicit read/write privileges on existing objects for all non-system schemas (including schema `CREATE`)
+- attempts to apply matching default privileges for discovered schema owners
+- by default grants `CONNECT` on all non-template databases
+- returns idempotent output for existing roles
+
+Required env vars for CLI run:
+- `PGHOST`
+- `PGUSER`
+- `PGPASSWORD`
+- `PGPORT` (optional, default `5432`)
+- `PGDATABASE` (optional, default `postgres`)
+- `PGSSLMODE` (optional, default `disable`)
+
+### 8. Manually refresh `template_medusa` from a different source DB

 Use this flow when you want preview DBs to be cloned from a new upstream data snapshot.

diff --git c/apps/zane-operator/package.json i/apps/zane-operator/package.json
index 0634e474..73c61cc3 100644
--- c/apps/zane-operator/package.json
+++ i/apps/zane-operator/package.json
@@ -6,6 +6,7 @@
   "scripts": {
     "dev": "bun --watch src/main.ts",
     "start": "bun src/main.ts",
+    "create:dev-user": "bun src/cli.ts create-dev-user",
     "build": "bun build src/main.ts --compile --outfile dist/zane-operator",
     "typecheck": "bunx tsc --noEmit -p tsconfig.json"
   }
diff --git c/apps/zane-operator/src/cli.ts i/apps/zane-operator/src/cli.ts
new file mode 100644
index 00000000..1cbeccd2
--- /dev/null
+++ i/apps/zane-operator/src/cli.ts
@@ -0,0 +1,142 @@
+import { SQL } from "bun"
+
+import { buildPostgresConnectionUrl } from "./config"
+import { BadRequestError, createOrUpdateDevRole } from "./db"
+
+interface CliArgs {
+  username: string
+  password: string
+  grantConnectToAllDatabases: boolean
+  allowProdBroadGrants: boolean
+}
+
+function printUsage(): void {
+  console.error("Usage:")
+  console.error(
+    "  bun src/cli.ts create-dev-user --username <name> --password <value> [--no-grant-connect-all-dbs] [--allow-prod-broad-grants]",
+  )
+}
+
+function readFlagValue(args: string[], index: number, flag: string): string {
+  const value = args[index + 1]
+  if (!value || value.startsWith("--")) {
+    throw new BadRequestError(`${flag} requires a value`)
+  }
+  return value
+}
+
+function parseCreateDevUserArgs(args: string[]): CliArgs {
+  let username = ""
+  let password = ""
+  let grantConnectToAllDatabases = true
+  let allowProdBroadGrants = false
+
+  for (let index = 0; index < args.length; index += 1) {
+    const arg = args[index]
+    if (arg === "--username") {
+      username = readFlagValue(args, index, "--username")
+      index += 1
+      continue
+    }
+
+    if (arg === "--password") {
+      password = readFlagValue(args, index, "--password")
+      index += 1
+      continue
+    }
+
+    if (arg === "--no-grant-connect-all-dbs") {
+      grantConnectToAllDatabases = false
+      continue
+    }
+
+    if (arg === "--allow-prod-broad-grants") {
+      allowProdBroadGrants = true
+      continue
+    }
+
+    throw new BadRequestError(`unknown argument: ${arg}`)
+  }
+
+  if (!username) {
+    throw new BadRequestError("--username is required")
+  }
+
+  if (!password) {
+    throw new BadRequestError("--password is required")
+  }
+
+  return {
+    username,
+    password,
+    grantConnectToAllDatabases,
+    allowProdBroadGrants,
+  }
+}
+
+async function runCreateDevUser(args: string[]): Promise<void> {
+  const parsed = parseCreateDevUserArgs(args)
+  const isProduction = process.env.NODE_ENV === "production"
+  if (isProduction && parsed.grantConnectToAllDatabases && !parsed.allowProdBroadGrants) {
+    throw new BadRequestError(
+      "broad cross-database grants are blocked in production by default; use --no-grant-connect-all-dbs or explicitly pass --allow-prod-broad-grants",
+    )
+  }
+
+  const databaseUrl = buildPostgresConnectionUrl(process.env)
+  const sql = new SQL({
+    url: databaseUrl,
+    max: 2,
+    idleTimeout: 10,
+    connectionTimeout: 10,
+  })
+
+  try {
+    await sql.connect()
+    const result = await createOrUpdateDevRole(sql, {
+      username: parsed.username,
+      password: parsed.password,
+      databaseUrl,
+      grantConnectToAllDatabases: parsed.grantConnectToAllDatabases,
+    })
+
+    console.info(
+      JSON.stringify({
+        event: "cli.create-dev-user",
+        username: result.username,
+        created: result.created,
+        connect_grants_applied: result.connectGrantsApplied,
+        schema_grants_applied: result.schemaGrantsApplied,
+        default_privilege_owners_applied: result.defaultPrivilegeOwnersApplied,
+        default_privilege_owners_skipped: result.defaultPrivilegeOwnersSkipped,
+        connect_grant_scope: parsed.grantConnectToAllDatabases ? "all_non_template_databases" : "none",
+      }),
+    )
+  } finally {
+    await sql.close({ timeout: 5 })
+  }
+}
+
+async function main(argv: string[]): Promise<void> {
+  const [command, ...args] = argv
+  if (command !== "create-dev-user") {
+    printUsage()
+    process.exit(1)
+  }
+
+  await runCreateDevUser(args)
+}
+
+try {
+  await main(process.argv.slice(2))
+} catch (error: unknown) {
+  if (error instanceof BadRequestError) {
+    console.error(error.message)
+    printUsage()
+    process.exit(1)
+  }
+
+  const message = error instanceof Error ? error.message : String(error)
+  console.error(message)
+  process.exit(1)
+}
diff --git c/apps/zane-operator/src/config.ts i/apps/zane-operator/src/config.ts
index 9f444aa6..a809f5e3 100644
--- c/apps/zane-operator/src/config.ts
+++ i/apps/zane-operator/src/config.ts
@@ -7,6 +7,7 @@ const DEFAULT_DB_PREVIEW_PREFIX = "medusa_pr_"
 const DEFAULT_DB_PREVIEW_OWNER = "zane_operator"
 const DEFAULT_DB_PREVIEW_APP_USER_PREFIX = "medusa_pr_app_"
 const DEFAULT_DB_PREVIEW_DEV_ROLE = "medusa_dev"
+const DEFAULT_DB_APP_SCHEMA = "medusa"
 const DEFAULT_PROTECTED_DB_NAMES = [
   "demo",
   "postgres",
@@ -26,6 +27,7 @@ export interface AppConfig {
   previewOwner: string
   previewAppUserPrefix: string
   previewDevRole: string
+  appSchema: string
   previewAppPasswordSecret: string
   protectedDbNames: Set<string>
 }
@@ -80,7 +82,7 @@ function parseProtectedDatabaseNames(rawValue: string | undefined): Set<string>
   return protectedNames
 }

-function buildPostgresConnectionUrl(env: Environment): string {
+export function buildPostgresConnectionUrl(env: Environment): string {
   const host = readRequiredEnv(env, "PGHOST")
   const port = parsePort(env.PGPORT, DEFAULT_PG_PORT, "PGPORT")
   const user = readRequiredEnv(env, "PGUSER")
@@ -105,14 +107,21 @@ export function loadConfig(env: Environment = process.env): AppConfig {
   const previewOwner = env.DB_PREVIEW_OWNER?.trim() || DEFAULT_DB_PREVIEW_OWNER
   const previewAppUserPrefix = env.DB_PREVIEW_APP_USER_PREFIX?.trim() || DEFAULT_DB_PREVIEW_APP_USER_PREFIX
   const previewDevRole = env.DB_PREVIEW_DEV_ROLE?.trim() || DEFAULT_DB_PREVIEW_DEV_ROLE
+  const appSchema = env.DB_APP_SCHEMA?.trim() || DEFAULT_DB_APP_SCHEMA
   const apiAuthToken = readRequiredEnv(env, "API_AUTH_TOKEN")
-  const previewAppPasswordSecret = env.DB_PREVIEW_APP_PASSWORD_SECRET?.trim() || apiAuthToken
+  const explicitPreviewSecret = env.DB_PREVIEW_APP_PASSWORD_SECRET?.trim()
+  const isProduction = env.NODE_ENV?.trim() === "production"
+  if (isProduction && !explicitPreviewSecret) {
+    throw new Error("DB_PREVIEW_APP_PASSWORD_SECRET is required when NODE_ENV=production")
+  }
+  const previewAppPasswordSecret = explicitPreviewSecret || apiAuthToken

   assertSafeIdentifier(previewPrefix, "DB_PREVIEW_PREFIX")
   assertSafeIdentifier(defaultTemplateName, "DB_TEMPLATE_NAME")
   assertSafeIdentifier(previewOwner, "DB_PREVIEW_OWNER")
   assertSafeIdentifier(previewAppUserPrefix, "DB_PREVIEW_APP_USER_PREFIX")
   assertSafeIdentifier(previewDevRole, "DB_PREVIEW_DEV_ROLE")
+  assertSafeIdentifier(appSchema, "DB_APP_SCHEMA")

   return {
     port: parsePort(env.PORT, DEFAULT_PORT, "PORT"),
@@ -123,6 +132,7 @@ export function loadConfig(env: Environment = process.env): AppConfig {
     previewOwner,
     previewAppUserPrefix,
     previewDevRole,
+    appSchema,
     previewAppPasswordSecret,
     protectedDbNames: parseProtectedDatabaseNames(env.DB_PROTECTED_NAMES),
   }
diff --git c/apps/zane-operator/src/db.ts i/apps/zane-operator/src/db.ts
index 846ea734..a8b349d7 100644
--- c/apps/zane-operator/src/db.ts
+++ i/apps/zane-operator/src/db.ts
@@ -28,6 +28,10 @@ function quoteIdentifier(identifier: string): string {
   return `"${identifier}"`
 }

+function quoteCatalogIdentifier(identifier: string): string {
+  return `"${identifier.replaceAll("\"", "\"\"")}"`
+}
+
 function quoteLiteral(value: string): string {
   return `'${value.replaceAll("'", "''")}'`
 }
@@ -124,13 +128,13 @@ function buildDatabaseUrl(databaseUrl: string, databaseName: string): string {
   return parsedUrl.toString()
 }

-async function withDatabaseClient<T>(
-  config: AppConfig,
+async function withDatabaseClientByUrl<T>(
+  databaseUrl: string,
   databaseName: string,
   operation: (databaseSql: Bun.SQL) => Promise<T>,
 ): Promise<T> {
   const databaseSql = new SQL({
-    url: buildDatabaseUrl(config.databaseUrl, databaseName),
+    url: buildDatabaseUrl(databaseUrl, databaseName),
     max: 4,
     idleTimeout: 10,
     connectionTimeout: 10,
@@ -144,6 +148,240 @@ async function withDatabaseClient<T>(
   }
 }

+async function withDatabaseClient<T>(
+  config: AppConfig,
+  databaseName: string,
+  operation: (databaseSql: Bun.SQL) => Promise<T>,
+): Promise<T> {
+  return await withDatabaseClientByUrl(config.databaseUrl, databaseName, operation)
+}
+
+interface DatabaseSchema {
+  name: string
+}
+
+async function listNonSystemSchemas(databaseSql: Bun.SQL): Promise<string[]> {
+  const rows = await databaseSql<DatabaseSchema[]>`
+    SELECT nspname AS "name"
+    FROM pg_namespace
+    WHERE nspname <> 'information_schema'
+      AND nspname NOT LIKE 'pg_%'
+    ORDER BY nspname ASC
+  `
+
+  return rows.map((row) => row.name)
+}
+
+async function grantReadWriteOnSchema(
+  databaseSql: Bun.SQL,
+  schemaName: string,
+  roleName: string,
+  includeCreate = false,
+): Promise<void> {
+  const quotedSchemaName = quoteCatalogIdentifier(schemaName)
+  const quotedRoleName = quoteIdentifier(roleName)
+
+  if (includeCreate) {
+    await databaseSql.unsafe(`GRANT USAGE, CREATE ON SCHEMA ${quotedSchemaName} TO ${quotedRoleName};`)
+  } else {
+    await databaseSql.unsafe(`GRANT USAGE ON SCHEMA ${quotedSchemaName} TO ${quotedRoleName};`)
+  }
+  await databaseSql.unsafe(
+    `GRANT SELECT, INSERT, UPDATE, DELETE, TRUNCATE, REFERENCES, TRIGGER, MAINTAIN ON ALL TABLES IN SCHEMA ${quotedSchemaName} TO ${quotedRoleName};`,
+  )
+  await databaseSql.unsafe(
+    `GRANT USAGE, SELECT, UPDATE ON ALL SEQUENCES IN SCHEMA ${quotedSchemaName} TO ${quotedRoleName};`,
+  )
+  await databaseSql.unsafe(`GRANT EXECUTE ON ALL ROUTINES IN SCHEMA ${quotedSchemaName} TO ${quotedRoleName};`)
+}
+
+interface SchemaOwnerRole {
+  owner: string
+}
+
+async function listSchemaOwnerRoles(databaseSql: Bun.SQL, schemaName: string): Promise<string[]> {
+  const rows = await databaseSql<SchemaOwnerRole[]>`
+    SELECT DISTINCT owner
+    FROM (
+      SELECT pg_get_userbyid(n.nspowner) AS owner
+      FROM pg_namespace n
+      WHERE n.nspname = ${schemaName}
+
+      UNION
+
+      SELECT pg_get_userbyid(c.relowner) AS owner
+      FROM pg_class c
+      JOIN pg_namespace n ON n.oid = c.relnamespace
+      WHERE n.nspname = ${schemaName}
+        AND c.relkind IN ('r', 'p', 'v', 'm', 'f', 'S')
+
+      UNION
+
+      SELECT pg_get_userbyid(p.proowner) AS owner
+      FROM pg_proc p
+      JOIN pg_namespace n ON n.oid = p.pronamespace
+      WHERE n.nspname = ${schemaName}
+    ) owners
+    WHERE owner IS NOT NULL
+      AND owner <> ''
+    ORDER BY owner ASC
+  `
+
+  return rows.map((row) => row.owner)
+}
+
+interface DefaultPrivilegeResult {
+  applied: number
+  skipped: number
+}
+
+async function grantReadWriteDefaultPrivilegesOnSchema(
+  databaseSql: Bun.SQL,
+  schemaName: string,
+  roleName: string,
+): Promise<DefaultPrivilegeResult> {
+  const quotedSchemaName = quoteCatalogIdentifier(schemaName)
+  const quotedRoleName = quoteIdentifier(roleName)
+  const owners = await listSchemaOwnerRoles(databaseSql, schemaName)
+
+  let applied = 0
+  let skipped = 0
+  for (const owner of owners) {
+    const quotedOwnerName = quoteCatalogIdentifier(owner)
+
+    try {
+      await databaseSql.unsafe(
+        `ALTER DEFAULT PRIVILEGES FOR ROLE ${quotedOwnerName} IN SCHEMA ${quotedSchemaName} GRANT SELECT, INSERT, UPDATE, DELETE, TRUNCATE, REFERENCES, TRIGGER, MAINTAIN ON TABLES TO ${quotedRoleName};`,
+      )
+      await databaseSql.unsafe(
+        `ALTER DEFAULT PRIVILEGES FOR ROLE ${quotedOwnerName} IN SCHEMA ${quotedSchemaName} GRANT USAGE, SELECT, UPDATE ON SEQUENCES TO ${quotedRoleName};`,
+      )
+      await databaseSql.unsafe(
+        `ALTER DEFAULT PRIVILEGES FOR ROLE ${quotedOwnerName} IN SCHEMA ${quotedSchemaName} GRANT EXECUTE ON ROUTINES TO ${quotedRoleName};`,
+      )
+      applied += 1
+    } catch (error: unknown) {
+      const message = error instanceof Error ? error.message : String(error)
+      const normalizedMessage = message.toLowerCase()
+      if (normalizedMessage.includes("must be member of role") || normalizedMessage.includes("permission denied")) {
+        skipped += 1
+        continue
+      }
+      throw error
+    }
+  }
+
+  return { applied, skipped }
+}
+
+async function ensureSchemaExists(databaseSql: Bun.SQL, schemaName: string, ownerRole: string): Promise<void> {
+  await databaseSql.unsafe(
+    `CREATE SCHEMA IF NOT EXISTS ${quoteCatalogIdentifier(schemaName)} AUTHORIZATION ${quoteIdentifier(ownerRole)};`,
+  )
+}
+
+async function lockDownPublicSchema(databaseSql: Bun.SQL): Promise<void> {
+  await databaseSql.unsafe("REVOKE ALL ON SCHEMA public FROM PUBLIC;")
+}
+
+async function setRoleSearchPath(sql: Bun.SQL, roleName: string, databaseName: string, schemaName: string): Promise<void> {
+  await sql.unsafe(
+    `ALTER ROLE ${quoteIdentifier(roleName)} IN DATABASE ${quoteIdentifier(databaseName)} SET search_path = ${quoteCatalogIdentifier(schemaName)}, pg_catalog;`,
+  )
+}
+
+async function transferSchemaOwnershipToRole(databaseSql: Bun.SQL, schemaName: string, targetRole: string): Promise<void> {
+  await databaseSql.unsafe(
+    `
+DO $do$
+DECLARE
+  rel RECORD;
+  routine RECORD;
+  custom_type RECORD;
+BEGIN
+  EXECUTE format('ALTER SCHEMA %I OWNER TO %I', ${quoteLiteral(schemaName)}, ${quoteLiteral(targetRole)});
+
+  FOR rel IN
+    SELECT c.oid, c.relkind, n.nspname, c.relname
+    FROM pg_class c
+    JOIN pg_namespace n ON n.oid = c.relnamespace
+    LEFT JOIN pg_depend d ON d.objid = c.oid AND d.deptype = 'e'
+    WHERE n.nspname = ${quoteLiteral(schemaName)}
+      AND c.relkind IN ('r', 'p', 'v', 'm', 'f', 'S')
+      AND d.objid IS NULL
+  LOOP
+    IF rel.relkind IN ('r', 'p', 'f') THEN
+      EXECUTE format('ALTER TABLE %I.%I OWNER TO %I', rel.nspname, rel.relname, ${quoteLiteral(targetRole)});
+    ELSIF rel.relkind = 'v' THEN
+      EXECUTE format('ALTER VIEW %I.%I OWNER TO %I', rel.nspname, rel.relname, ${quoteLiteral(targetRole)});
+    ELSIF rel.relkind = 'm' THEN
+      EXECUTE format('ALTER MATERIALIZED VIEW %I.%I OWNER TO %I', rel.nspname, rel.relname, ${quoteLiteral(targetRole)});
+    ELSIF rel.relkind = 'S' THEN
+      EXECUTE format('ALTER SEQUENCE %I.%I OWNER TO %I', rel.nspname, rel.relname, ${quoteLiteral(targetRole)});
+    END IF;
+  END LOOP;
+
+  FOR routine IN
+    SELECT p.oid::regprocedure AS identity
+    FROM pg_proc p
+    JOIN pg_namespace n ON n.oid = p.pronamespace
+    LEFT JOIN pg_depend d ON d.objid = p.oid AND d.deptype = 'e'
+    WHERE n.nspname = ${quoteLiteral(schemaName)}
+      AND d.objid IS NULL
+  LOOP
+    EXECUTE format('ALTER ROUTINE %s OWNER TO %I', routine.identity, ${quoteLiteral(targetRole)});
+  END LOOP;
+
+  FOR custom_type IN
+    SELECT format('%I.%I', n.nspname, t.typname) AS identity
+    FROM pg_type t
+    JOIN pg_namespace n ON n.oid = t.typnamespace
+    LEFT JOIN pg_depend d ON d.objid = t.oid AND d.deptype = 'e'
+    WHERE n.nspname = ${quoteLiteral(schemaName)}
+      AND t.typtype IN ('d', 'e')
+      AND d.objid IS NULL
+  LOOP
+    EXECUTE format('ALTER TYPE %s OWNER TO %I', custom_type.identity, ${quoteLiteral(targetRole)});
+  END LOOP;
+END
+$do$;
+`,
+  )
+}
+
+async function grantAppRoleOnSchema(databaseSql: Bun.SQL, schemaName: string, appRoleName: string): Promise<void> {
+  const quotedSchemaName = quoteCatalogIdentifier(schemaName)
+  const quotedAppRoleName = quoteIdentifier(appRoleName)
+
+  await databaseSql.unsafe(`GRANT USAGE, CREATE ON SCHEMA ${quotedSchemaName} TO ${quotedAppRoleName};`)
+  await databaseSql.unsafe(`GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA ${quotedSchemaName} TO ${quotedAppRoleName};`)
+  await databaseSql.unsafe(`GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA ${quotedSchemaName} TO ${quotedAppRoleName};`)
+  await databaseSql.unsafe(`GRANT EXECUTE ON ALL ROUTINES IN SCHEMA ${quotedSchemaName} TO ${quotedAppRoleName};`)
+}
+
+async function revokeAppRoleOutsideSchema(
+  databaseSql: Bun.SQL,
+  appRoleName: string,
+  allowedSchemaName: string,
+): Promise<void> {
+  const schemas = await listNonSystemSchemas(databaseSql)
+  for (const schemaName of schemas) {
+    if (schemaName === allowedSchemaName) {
+      continue
+    }
+
+    const quotedSchemaName = quoteCatalogIdentifier(schemaName)
+    const quotedAppRoleName = quoteIdentifier(appRoleName)
+
+    await databaseSql.unsafe(`REVOKE ALL PRIVILEGES ON SCHEMA ${quotedSchemaName} FROM ${quotedAppRoleName};`)
+    await databaseSql.unsafe(`REVOKE ALL PRIVILEGES ON ALL TABLES IN SCHEMA ${quotedSchemaName} FROM ${quotedAppRoleName};`)
+    await databaseSql.unsafe(
+      `REVOKE ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA ${quotedSchemaName} FROM ${quotedAppRoleName};`,
+    )
+    await databaseSql.unsafe(`REVOKE ALL PRIVILEGES ON ALL ROUTINES IN SCHEMA ${quotedSchemaName} FROM ${quotedAppRoleName};`)
+  }
+}
+
 async function withAdvisoryLock<T>(
   sql: Bun.SQL,
   lockKey: string,
@@ -184,7 +422,9 @@ async function ensurePreviewAppRole(

   // Allow preview owner to manage default privileges for app role objects.
   if (previewOwner !== appRoleName) {
-    await sql.unsafe(`GRANT ${quoteIdentifier(appRoleName)} TO ${quoteIdentifier(previewOwner)};`)
+    await sql.unsafe(
+      `GRANT ${quoteIdentifier(appRoleName)} TO ${quoteIdentifier(previewOwner)} WITH INHERIT FALSE, SET TRUE, ADMIN FALSE;`,
+    )
   }
 }

@@ -204,35 +444,38 @@ async function syncPreviewDatabaseGrants(
     throw new BadRequestError(`configured dev role "${devRole}" does not exist`)
   }

-  await sql.unsafe(`GRANT CONNECT, TEMP ON DATABASE ${quoteIdentifier(dbName)} TO ${quoteIdentifier(appRoleName)};`)
-  await sql.unsafe(`GRANT CONNECT, TEMP ON DATABASE ${quoteIdentifier(dbName)} TO ${quoteIdentifier(devRole)};`)
+  await sql.unsafe(
+    `REVOKE CREATE, TEMPORARY ON DATABASE ${quoteIdentifier(dbName)} FROM ${quoteIdentifier(appRoleName)};`,
+  )
+  await sql.unsafe(`REVOKE CONNECT, TEMPORARY ON DATABASE ${quoteIdentifier(dbName)} FROM PUBLIC;`)
+  await sql.unsafe(`GRANT CONNECT ON DATABASE ${quoteIdentifier(dbName)} TO ${quoteIdentifier(appRoleName)};`)
+  await sql.unsafe(`GRANT CONNECT ON DATABASE ${quoteIdentifier(dbName)} TO ${quoteIdentifier(devRole)};`)
+  await setRoleSearchPath(sql, appRoleName, dbName, config.appSchema)

   await withDatabaseClient(config, dbName, async (dbSql) => {
-    await dbSql.unsafe(`GRANT USAGE, CREATE ON SCHEMA public TO ${quoteIdentifier(appRoleName)};`)
-    await dbSql.unsafe(`GRANT USAGE, CREATE ON SCHEMA public TO ${quoteIdentifier(devRole)};`)
+    await lockDownPublicSchema(dbSql)
+    await ensureSchemaExists(dbSql, config.appSchema, appRoleName)
+    await transferSchemaOwnershipToRole(dbSql, config.appSchema, appRoleName)
+    await grantAppRoleOnSchema(dbSql, config.appSchema, appRoleName)
+    await revokeAppRoleOutsideSchema(dbSql, appRoleName, config.appSchema)

-    await dbSql.unsafe(
-      `GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO ${quoteIdentifier(appRoleName)}, ${quoteIdentifier(devRole)};`,
-    )
-    await dbSql.unsafe(
-      `GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public TO ${quoteIdentifier(appRoleName)}, ${quoteIdentifier(devRole)};`,
-    )
-    await dbSql.unsafe(
-      `GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA public TO ${quoteIdentifier(appRoleName)}, ${quoteIdentifier(devRole)};`,
-    )
-
-    // Ensure template-cloned objects owned by the executing role become writable for the preview app role.
-    await dbSql.unsafe(`REASSIGN OWNED BY CURRENT_USER TO ${quoteIdentifier(appRoleName)};`)
-    await dbSql.unsafe(`ALTER SCHEMA public OWNER TO ${quoteIdentifier(appRoleName)};`)
+    const schemas = await listNonSystemSchemas(dbSql)
+    for (const schemaName of schemas) {
+      await grantReadWriteOnSchema(dbSql, schemaName, devRole, true)
+      await grantReadWriteDefaultPrivilegesOnSchema(dbSql, schemaName, devRole)
+    }

+    const quotedSchemaName = quoteCatalogIdentifier(config.appSchema)
+    const quotedAppRoleName = quoteIdentifier(appRoleName)
+    const quotedDevRoleName = quoteIdentifier(devRole)
     await dbSql.unsafe(
-      `ALTER DEFAULT PRIVILEGES FOR ROLE ${quoteIdentifier(appRoleName)} IN SCHEMA public GRANT ALL PRIVILEGES ON TABLES TO ${quoteIdentifier(appRoleName)}, ${quoteIdentifier(devRole)};`,
+      `ALTER DEFAULT PRIVILEGES FOR ROLE ${quotedAppRoleName} IN SCHEMA ${quotedSchemaName} GRANT ALL PRIVILEGES ON TABLES TO ${quotedAppRoleName}, ${quotedDevRoleName};`,
     )
     await dbSql.unsafe(
-      `ALTER DEFAULT PRIVILEGES FOR ROLE ${quoteIdentifier(appRoleName)} IN SCHEMA public GRANT ALL PRIVILEGES ON SEQUENCES TO ${quoteIdentifier(appRoleName)}, ${quoteIdentifier(devRole)};`,
+      `ALTER DEFAULT PRIVILEGES FOR ROLE ${quotedAppRoleName} IN SCHEMA ${quotedSchemaName} GRANT ALL PRIVILEGES ON SEQUENCES TO ${quotedAppRoleName}, ${quotedDevRoleName};`,
     )
     await dbSql.unsafe(
-      `ALTER DEFAULT PRIVILEGES FOR ROLE ${quoteIdentifier(appRoleName)} IN SCHEMA public GRANT EXECUTE ON FUNCTIONS TO ${quoteIdentifier(appRoleName)}, ${quoteIdentifier(devRole)};`,
+      `ALTER DEFAULT PRIVILEGES FOR ROLE ${quotedAppRoleName} IN SCHEMA ${quotedSchemaName} GRANT EXECUTE ON ROUTINES TO ${quotedAppRoleName}, ${quotedDevRoleName};`,
     )
   })
 }
@@ -288,6 +531,50 @@ async function countActiveDatabaseConnections(sql: Bun.SQL, databaseName: string
   return rows[0]?.active_connections ?? 0
 }

+interface ActiveConnectionByRole {
+  role: string
+  activeConnections: number
+}
+
+function sanitizeRoleName(roleName: string): string {
+  const normalized = roleName.trim() || "unknown"
+  return normalized.replaceAll(/[^A-Za-z0-9_:@.-]/g, "?").slice(0, 63)
+}
+
+function formatActiveConnectionsByRole(rows: ActiveConnectionByRole[]): string {
+  if (rows.length === 0) {
+    return "none"
+  }
+
+  return rows.map((entry) => `${sanitizeRoleName(entry.role)}(${entry.activeConnections})`).join(", ")
+}
+
+async function getActiveConnectionsByRole(sql: Bun.SQL, databaseName: string): Promise<ActiveConnectionByRole[]> {
+  const rows = await sql<{ role: string | null; active_connections: number }[]>`
+    SELECT COALESCE(usename, 'unknown') AS "role",
+           COUNT(*)::int AS "active_connections"
+    FROM pg_stat_activity
+    WHERE datname = ${databaseName}
+      AND pid <> pg_backend_pid()
+    GROUP BY usename
+    ORDER BY COUNT(*) DESC, COALESCE(usename, 'unknown') ASC
+  `
+
+  return rows.map((row) => ({
+    role: row.role ?? "unknown",
+    activeConnections: row.active_connections,
+  }))
+}
+
+async function getActiveConnectionSummary(sql: Bun.SQL, databaseName: string): Promise<string> {
+  try {
+    const rows = await getActiveConnectionsByRole(sql, databaseName)
+    return formatActiveConnectionsByRole(rows)
+  } catch {
+    return "unavailable"
+  }
+}
+
 async function dropDatabase(sql: Bun.SQL, databaseName: string): Promise<void> {
   const quotedDbName = quoteIdentifier(databaseName)

@@ -304,14 +591,16 @@ async function dropDatabase(sql: Bun.SQL, databaseName: string): Promise<void> {
     }

     if (normalizedMessage.includes("permission denied to terminate process")) {
+      const roleSummary = await getActiveConnectionSummary(sql, databaseName)
       throw new BadRequestError(
-        "database role cannot terminate active sessions required for DROP DATABASE ... WITH (FORCE). Grant pg_signal_backend to the operator role (e.g. GRANT pg_signal_backend TO zane_operator).",
+        `database role cannot terminate active sessions required for DROP DATABASE ... WITH (FORCE). Grant pg_signal_backend to the operator role (e.g. GRANT pg_signal_backend TO zane_operator). Active connections by role: ${roleSummary}.`,
       )
     }

     if (normalizedMessage.includes("is being accessed by other users")) {
+      const roleSummary = await getActiveConnectionSummary(sql, databaseName)
       throw new BadRequestError(
-        "database still has active connections and could not be dropped. Ensure teardown role can terminate backends for the target preview database.",
+        `database still has active connections and could not be dropped. Ensure teardown role can terminate backends for the target preview database. Active connections by role: ${roleSummary}.`,
       )
     }

@@ -339,20 +628,117 @@ async function dropPreviewAppRole(sql: Bun.SQL, roleName: string): Promise<boole
   }
 }

+interface NonTemplateDatabase {
+  name: string
+}
+
+async function listNonTemplateDatabases(sql: Bun.SQL): Promise<string[]> {
+  const rows = await sql<NonTemplateDatabase[]>`
+    SELECT datname AS "name"
+    FROM pg_database
+    WHERE datistemplate = false
+    ORDER BY datname ASC
+  `
+
+  return rows.map((row) => row.name)
+}
+
+export interface CreateOrUpdateDevRoleParams {
+  username: string
+  password: string
+  databaseUrl: string
+  grantConnectToAllDatabases: boolean
+}
+
+export interface CreateOrUpdateDevRoleResult {
+  username: string
+  created: boolean
+  connectGrantsApplied: number
+  schemaGrantsApplied: number
+  defaultPrivilegeOwnersApplied: number
+  defaultPrivilegeOwnersSkipped: number
+}
+
+export async function createOrUpdateDevRole(
+  sql: Bun.SQL,
+  params: CreateOrUpdateDevRoleParams,
+): Promise<CreateOrUpdateDevRoleResult> {
+  const username = normalizeIdentifier(params.username, "username")
+  if (!params.password) {
+    throw new BadRequestError("password cannot be empty")
+  }
+
+  const exists = await roleExists(sql, username)
+  if (!exists) {
+    await sql.unsafe(`CREATE ROLE ${quoteIdentifier(username)} LOGIN;`)
+  }
+
+  await sql.unsafe(
+    `ALTER ROLE ${quoteIdentifier(username)} WITH LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOBYPASSRLS INHERIT PASSWORD ${quoteLiteral(params.password)};`,
+  )
+
+  let connectGrantsApplied = 0
+  let schemaGrantsApplied = 0
+  let defaultPrivilegeOwnersApplied = 0
+  let defaultPrivilegeOwnersSkipped = 0
+  if (params.grantConnectToAllDatabases) {
+    const databases = await listNonTemplateDatabases(sql)
+    for (const databaseName of databases) {
+      await sql.unsafe(
+        `GRANT CONNECT ON DATABASE ${quoteCatalogIdentifier(databaseName)} TO ${quoteIdentifier(username)};`,
+      )
+      connectGrantsApplied += 1
+
+      await withDatabaseClientByUrl(params.databaseUrl, databaseName, async (databaseSql) => {
+        const schemas = await listNonSystemSchemas(databaseSql)
+        for (const schemaName of schemas) {
+          await grantReadWriteOnSchema(databaseSql, schemaName, username, true)
+          schemaGrantsApplied += 1
+
+          const defaultPrivilegeResult = await grantReadWriteDefaultPrivilegesOnSchema(databaseSql, schemaName, username)
+          defaultPrivilegeOwnersApplied += defaultPrivilegeResult.applied
+          defaultPrivilegeOwnersSkipped += defaultPrivilegeResult.skipped
+        }
+      })
+    }
+  }
+
+  return {
+    username,
+    created: !exists,
+    connectGrantsApplied,
+    schemaGrantsApplied,
+    defaultPrivilegeOwnersApplied,
+    defaultPrivilegeOwnersSkipped,
+  }
+}
+
+export interface TeardownPreviewDatabaseResult {
+  dbName: string
+  deleted: boolean
+  terminatedConnections: number
+  appUser: string
+  roleDeleted: boolean
+  devGrantsCleaned: boolean
+  noop: boolean
+  noopReason: "database_not_found" | null
+}
+
 export async function teardownPreviewDatabase(
   sql: Bun.SQL,
   config: AppConfig,
   prNumber: number,
-): Promise<{ dbName: string; deleted: boolean; terminatedConnections: number; appUser: string; roleDeleted: boolean }> {
+): Promise<TeardownPreviewDatabaseResult> {
   const dbName = buildPreviewDatabaseName(config.previewPrefix, prNumber)
   const appUser = buildPreviewAppRoleName(config.previewAppUserPrefix, prNumber)
   assertSafeTargetDatabaseName(dbName, config)

   return await withAdvisoryLock(sql, dbName, async (lockedSql) => {
+    const exists = await databaseExists(lockedSql, dbName)
     let deleted = false
     let terminatedConnections = 0

-    if (await databaseExists(lockedSql, dbName)) {
+    if (exists) {
       terminatedConnections = await countActiveDatabaseConnections(lockedSql, dbName)
       await dropDatabase(lockedSql, dbName)
       deleted = true
@@ -360,6 +746,15 @@ export async function teardownPreviewDatabase(

     const roleDeleted = await dropPreviewAppRole(lockedSql, appUser)

-    return { dbName, deleted, terminatedConnections, appUser, roleDeleted }
+    return {
+      dbName,
+      deleted,
+      terminatedConnections,
+      appUser,
+      roleDeleted,
+      devGrantsCleaned: deleted,
+      noop: !exists,
+      noopReason: exists ? null : "database_not_found",
+    }
   })
 }
diff --git c/apps/zane-operator/src/handlers/teardown-preview-db.ts i/apps/zane-operator/src/handlers/teardown-preview-db.ts
index 1d0003a6..25c2b5ad 100644
--- c/apps/zane-operator/src/handlers/teardown-preview-db.ts
+++ i/apps/zane-operator/src/handlers/teardown-preview-db.ts
@@ -23,6 +23,9 @@ export async function handleTeardownPreviewDb(
         deleted: result.deleted,
         app_user: result.appUser,
         role_deleted: result.roleDeleted,
+        dev_grants_cleaned: result.devGrantsCleaned,
+        noop: result.noop,
+        noop_reason: result.noopReason,
         terminated_connections: result.terminatedConnections,
       }),
     )
@@ -32,6 +35,9 @@ export async function handleTeardownPreviewDb(
       deleted: result.deleted,
       app_user: result.appUser,
       role_deleted: result.roleDeleted,
+      dev_grants_cleaned: result.devGrantsCleaned,
+      noop: result.noop,
+      noop_reason: result.noopReason,
     })
   } catch (error: unknown) {
     return mapHandlerError(error, "teardown-preview-db")
diff --git c/docker-compose.yaml i/docker-compose.yaml
index fab0d1da..1495bfe6 100644
--- c/docker-compose.yaml
+++ i/docker-compose.yaml
@@ -1,8 +1,9 @@
 x-medusa-env: &medusa-env
+  NODE_ENV: ${DC_NODE_ENV:-development}
   JWT_SECRET: ${DC_JWT_SECRET:-supersecret}
   COOKIE_SECRET: ${DC_COOKIE_SECRET:-supersecret}
   DATABASE_TYPE: postgres
-  DATABASE_URL: ${DC_DATABASE_URL}
+  DATABASE_URL: postgresql://${DC_MEDUSA_APP_DB_USER:-medusa_app}:${DC_MEDUSA_APP_DB_PASSWORD:-medusa_app_change_me}@medusa-db:5432/${DC_MEDUSA_APP_DB_NAME:-medusa}?sslmode=${DC_POSTGRES_SSLMODE:-disable}
   LEGACY_DATABASE_URL: ${DC_LEGACY_DATABASE_URL}
   MINIO_FILE_URL: ${DC_MINIO_FILE_URL}
   MINIO_REGION: ${DC_MINIO_REGION}
@@ -22,6 +23,25 @@ x-medusa-env: &medusa-env
   FEATURE_PPL_ENABLED: ${DC_FEATURE_PPL_ENABLED:-0}
   PPL_ENVIRONMENT: ${DC_PPL_ENVIRONMENT:-testing}

+x-zane-operator-env: &zane-operator-env
+  PORT: ${DC_ZANE_OPERATOR_PORT:-8080}
+  NODE_ENV: ${DC_NODE_ENV:-development}
+  API_AUTH_TOKEN: ${DC_ZANE_OPERATOR_API_AUTH_TOKEN:-replace-me}
+  PGHOST: medusa-db
+  PGPORT: 5432
+  PGUSER: ${DC_ZANE_OPERATOR_PGUSER:-zane_operator}
+  PGPASSWORD: ${DC_ZANE_OPERATOR_PGPASSWORD:-replace-me}
+  PGDATABASE: ${DC_ZANE_OPERATOR_PGDATABASE:-postgres}
+  PGSSLMODE: ${DC_POSTGRES_SSLMODE:-disable}
+  DB_TEMPLATE_NAME: ${DC_ZANE_OPERATOR_DB_TEMPLATE_NAME:-template_medusa}
+  DB_PREVIEW_PREFIX: ${DC_ZANE_OPERATOR_DB_PREVIEW_PREFIX:-medusa_pr_}
+  DB_PREVIEW_OWNER: ${DC_ZANE_OPERATOR_PGUSER:-zane_operator}
+  DB_PREVIEW_APP_USER_PREFIX: ${DC_ZANE_OPERATOR_DB_PREVIEW_APP_USER_PREFIX:-medusa_pr_app_}
+  DB_PREVIEW_DEV_ROLE: ${DC_MEDUSA_DEV_DB_USER:-medusa_dev}
+  DB_APP_SCHEMA: ${DC_MEDUSA_APP_DB_SCHEMA:-medusa}
+  DB_PREVIEW_APP_PASSWORD_SECRET: ${DC_ZANE_OPERATOR_DB_PREVIEW_APP_PASSWORD_SECRET:-replace-me}
+  DB_PROTECTED_NAMES: ${DC_ZANE_OPERATOR_DB_PROTECTED_NAMES:-demo,postgres,template0,template1,template_medusa}
+
 x-medusa-base: &medusa-base
   restart: unless-stopped
   container_name: wr_medusa_be
@@ -148,7 +168,7 @@ services:
     restart: unless-stopped
     command: ["postgres", "-cshared_preload_libraries=pg_stat_statements"]
     ports:
-      - "5432:5432"
+      - "${DC_POSTGRES_PUBLIC_PORT:-5432}:5432"
     volumes:
       - ${DC_POSTGRES_VOLUME_DATA:-./.docker_data/db}:/var/lib/postgresql
       - ./docker/development/postgres/initdb:/docker-entrypoint-initdb.d:ro
@@ -161,9 +181,9 @@ services:
       timeout: 60s
       retries: 3
     environment:
-      POSTGRES_PASSWORD: root
-      POSTGRES_USER: root
-      POSTGRES_DB: medusa
+      POSTGRES_PASSWORD: ${DC_POSTGRES_SUPERUSER_PASSWORD:-root}
+      POSTGRES_USER: ${DC_POSTGRES_SUPERUSER:-root}
+      POSTGRES_DB: ${DC_MEDUSA_APP_DB_NAME:-medusa}
       PGDATA: /var/lib/postgresql/18/docker
       ZANE_OPERATOR_DB_USER: ${DC_ZANE_OPERATOR_DB_USER:-zane_operator}
       ZANE_OPERATOR_DB_PASSWORD: ${DC_ZANE_OPERATOR_DB_PASSWORD:-zane_operator_change_me}
@@ -172,6 +192,21 @@ services:
       MEDUSA_DEV_DB_USER: ${DC_MEDUSA_DEV_DB_USER:-medusa_dev}
       MEDUSA_DEV_DB_PASSWORD: ${DC_MEDUSA_DEV_DB_PASSWORD:-medusa_dev_change_me}
       MEDUSA_APP_DB_NAME: ${DC_MEDUSA_APP_DB_NAME:-medusa}
+      MEDUSA_APP_DB_SCHEMA: ${DC_MEDUSA_APP_DB_SCHEMA:-medusa}
+  zane-operator:
+    restart: unless-stopped
+    build:
+      context: .
+      dockerfile: docker/development/zane-operator/Dockerfile
+    depends_on:
+      medusa-db:
+        condition: service_healthy
+    ports:
+      - "${DC_ZANE_OPERATOR_PUBLIC_PORT:-8082}:${DC_ZANE_OPERATOR_PORT:-8080}"
+    networks:
+      - internal
+    environment:
+      <<: *zane-operator-env
   engine-db:
     image: mariadb:latest
     restart: no
diff --git c/docker/development/postgres/initdb/01-zane-role-bootstrap.sh i/docker/development/postgres/initdb/01-zane-role-bootstrap.sh
index 3e968dbc..372561fd 100644
--- c/docker/development/postgres/initdb/01-zane-role-bootstrap.sh
+++ i/docker/development/postgres/initdb/01-zane-role-bootstrap.sh
@@ -22,83 +22,77 @@ require_identifier() {
 : "${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}"
 : "${POSTGRES_DB:=medusa}"

-: "${ZANE_OPERATOR_DB_USER:=zane_operator}"
-: "${ZANE_OPERATOR_DB_PASSWORD:=zane_operator_change_me}"
 : "${MEDUSA_APP_DB_USER:=medusa_app}"
 : "${MEDUSA_APP_DB_PASSWORD:=medusa_app_change_me}"
 : "${MEDUSA_DEV_DB_USER:=medusa_dev}"
 : "${MEDUSA_DEV_DB_PASSWORD:=medusa_dev_change_me}"
 : "${MEDUSA_APP_DB_NAME:=${POSTGRES_DB}}"
+: "${MEDUSA_APP_DB_SCHEMA:=medusa}"

 require_identifier "POSTGRES_USER" "$POSTGRES_USER"
 require_identifier "POSTGRES_DB" "$POSTGRES_DB"
-require_identifier "ZANE_OPERATOR_DB_USER" "$ZANE_OPERATOR_DB_USER"
 require_identifier "MEDUSA_APP_DB_USER" "$MEDUSA_APP_DB_USER"
 require_identifier "MEDUSA_DEV_DB_USER" "$MEDUSA_DEV_DB_USER"
 require_identifier "MEDUSA_APP_DB_NAME" "$MEDUSA_APP_DB_NAME"
+require_identifier "MEDUSA_APP_DB_SCHEMA" "$MEDUSA_APP_DB_SCHEMA"

 export PGPASSWORD="$POSTGRES_PASSWORD"

-log "Bootstrapping roles and grants (zane-operator/app/dev)"
+log "Bootstrapping roles and grants (app/dev)"
 psql --username "$POSTGRES_USER" --dbname postgres \
   -v ON_ERROR_STOP=1 \
   -v postgres_user="$POSTGRES_USER" \
   -v app_db="$MEDUSA_APP_DB_NAME" \
-  -v zane_user="$ZANE_OPERATOR_DB_USER" \
-  -v zane_pass="$ZANE_OPERATOR_DB_PASSWORD" \
+  -v app_schema="$MEDUSA_APP_DB_SCHEMA" \
   -v app_user="$MEDUSA_APP_DB_USER" \
   -v app_pass="$MEDUSA_APP_DB_PASSWORD" \
   -v dev_user="$MEDUSA_DEV_DB_USER" \
   -v dev_pass="$MEDUSA_DEV_DB_PASSWORD" <<'SQL'
-DO $do$
-BEGIN
-  IF NOT EXISTS (SELECT 1 FROM pg_catalog.pg_roles WHERE rolname = :'zane_user') THEN
-    EXECUTE format('CREATE ROLE %I LOGIN', :'zane_user');
-  END IF;
-
-  EXECUTE format(
-    'ALTER ROLE %I WITH LOGIN CREATEDB NOSUPERUSER NOCREATEROLE NOBYPASSRLS INHERIT PASSWORD %L',
-    :'zane_user',
-    :'zane_pass'
-  );
-  EXECUTE format('GRANT pg_signal_backend TO %I', :'zane_user');
-END
-$do$;
+SELECT set_config('zane.postgres_user', :'postgres_user', false);
+SELECT set_config('zane.app_db', :'app_db', false);
+SELECT set_config('zane.app_schema', :'app_schema', false);
+SELECT set_config('zane.app_user', :'app_user', false);
+SELECT set_config('zane.app_pass', :'app_pass', false);
+SELECT set_config('zane.dev_user', :'dev_user', false);
+SELECT set_config('zane.dev_pass', :'dev_pass', false);

 DO $do$
+DECLARE
+  app_user text := current_setting('zane.app_user');
+  app_pass text := current_setting('zane.app_pass');
 BEGIN
-  IF NOT EXISTS (SELECT 1 FROM pg_catalog.pg_roles WHERE rolname = :'app_user') THEN
-    EXECUTE format('CREATE ROLE %I LOGIN', :'app_user');
+  IF NOT EXISTS (SELECT 1 FROM pg_catalog.pg_roles WHERE rolname = app_user) THEN
+    EXECUTE format('CREATE ROLE %I LOGIN', app_user);
   END IF;

   EXECUTE format(
     'ALTER ROLE %I WITH LOGIN NOCREATEDB NOSUPERUSER NOCREATEROLE NOBYPASSRLS INHERIT PASSWORD %L',
-    :'app_user',
-    :'app_pass'
+    app_user,
+    app_pass
   );
 END
 $do$;

-DO $do$
-BEGIN
-  IF NOT EXISTS (SELECT 1 FROM pg_catalog.pg_roles WHERE rolname = :'dev_user') THEN
-    EXECUTE format('CREATE ROLE %I LOGIN', :'dev_user');
-  END IF;
-
-  EXECUTE format(
-    'ALTER ROLE %I WITH LOGIN NOCREATEDB NOSUPERUSER NOCREATEROLE NOBYPASSRLS INHERIT PASSWORD %L',
-    :'dev_user',
-    :'dev_pass'
-  );
-
-  EXECUTE format('GRANT pg_read_all_data TO %I', :'dev_user');
-  EXECUTE format('GRANT pg_write_all_data TO %I', :'dev_user');
-  EXECUTE format('GRANT pg_monitor TO %I', :'dev_user');
-END
-$do$;
-
 DO $do$
 DECLARE
+  dev_user text := current_setting('zane.dev_user');
+  dev_pass text := current_setting('zane.dev_pass');
+BEGIN
+  IF NOT EXISTS (SELECT 1 FROM pg_catalog.pg_roles WHERE rolname = dev_user) THEN
+    EXECUTE format('CREATE ROLE %I LOGIN', dev_user);
+  END IF;
+
+  EXECUTE format(
+    'ALTER ROLE %I WITH LOGIN NOCREATEDB NOSUPERUSER NOCREATEROLE NOBYPASSRLS INHERIT PASSWORD %L',
+    dev_user,
+    dev_pass
+  );
+END
+$do$;
+
+DO $do$
+DECLARE
+  dev_user text := current_setting('zane.dev_user');
   db_record RECORD;
 BEGIN
   FOR db_record IN
@@ -106,20 +100,31 @@ BEGIN
     FROM pg_database
     WHERE datistemplate = false
   LOOP
-    EXECUTE format('GRANT CONNECT, TEMP ON DATABASE %I TO %I', db_record.datname, :'zane_user');
-    EXECUTE format('GRANT CONNECT ON DATABASE %I TO %I', db_record.datname, :'dev_user');
+    EXECUTE format('GRANT CONNECT ON DATABASE %I TO %I', db_record.datname, dev_user);
   END LOOP;
 END
 $do$;

 DO $do$
+DECLARE
+  app_db text := current_setting('zane.app_db');
+  app_schema text := current_setting('zane.app_schema');
+  app_user text := current_setting('zane.app_user');
+  dev_user text := current_setting('zane.dev_user');
+  postgres_user text := current_setting('zane.postgres_user');
 BEGIN
-  IF EXISTS (SELECT 1 FROM pg_database WHERE datname = :'app_db') THEN
-    EXECUTE format('ALTER DATABASE %I OWNER TO %I', :'app_db', :'app_user');
-    EXECUTE format('REVOKE CONNECT ON DATABASE %I FROM PUBLIC', :'app_db');
-    EXECUTE format('GRANT CONNECT, TEMP ON DATABASE %I TO %I', :'app_db', :'app_user');
-    EXECUTE format('GRANT CONNECT, TEMP ON DATABASE %I TO %I', :'app_db', :'zane_user');
-    EXECUTE format('GRANT ALL PRIVILEGES ON DATABASE %I TO %I', :'app_db', :'dev_user');
+  IF EXISTS (SELECT 1 FROM pg_database WHERE datname = app_db) THEN
+    EXECUTE format('ALTER DATABASE %I OWNER TO %I', app_db, postgres_user);
+    EXECUTE format('REVOKE CONNECT, TEMPORARY ON DATABASE %I FROM PUBLIC', app_db);
+    EXECUTE format('REVOKE CREATE, TEMPORARY ON DATABASE %I FROM %I', app_db, app_user);
+    EXECUTE format('GRANT CONNECT ON DATABASE %I TO %I', app_db, app_user);
+    EXECUTE format('GRANT CONNECT ON DATABASE %I TO %I', app_db, dev_user);
+    EXECUTE format(
+      'ALTER ROLE %I IN DATABASE %I SET search_path = %I, pg_catalog',
+      app_user,
+      app_db,
+      app_schema
+    );
   END IF;
 END
 $do$;
@@ -131,35 +136,302 @@ if [ "$app_db_exists" = "1" ]; then
   psql --username "$POSTGRES_USER" --dbname "$MEDUSA_APP_DB_NAME" \
     -v ON_ERROR_STOP=1 \
     -v postgres_user="$POSTGRES_USER" \
+    -v app_schema="$MEDUSA_APP_DB_SCHEMA" \
     -v app_user="$MEDUSA_APP_DB_USER" \
     -v dev_user="$MEDUSA_DEV_DB_USER" <<'SQL'
-  REVOKE CREATE ON SCHEMA public FROM PUBLIC;
-  GRANT USAGE, CREATE ON SCHEMA public TO :"app_user";
-  GRANT USAGE, CREATE ON SCHEMA public TO :"dev_user";
+SELECT set_config('zane.postgres_user', :'postgres_user', false);
+SELECT set_config('zane.app_schema', :'app_schema', false);
+SELECT set_config('zane.app_user', :'app_user', false);
+SELECT set_config('zane.dev_user', :'dev_user', false);

-  GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO :"app_user";
-  GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO :"dev_user";
-  GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public TO :"app_user";
-  GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public TO :"dev_user";
-  GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA public TO :"app_user";
-  GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA public TO :"dev_user";
+DO $do$
+DECLARE
+  app_schema text := current_setting('zane.app_schema');
+  app_user text := current_setting('zane.app_user');
+  dev_user text := current_setting('zane.dev_user');
+  postgres_user text := current_setting('zane.postgres_user');
+  schema_record RECORD;
+  rel_record RECORD;
+  routine_record RECORD;
+  type_record RECORD;
+BEGIN
+  EXECUTE format('CREATE SCHEMA IF NOT EXISTS %I AUTHORIZATION %I', app_schema, app_user);
+  -- Enforce secure schema usage pattern for this database.
+  EXECUTE 'REVOKE ALL ON SCHEMA public FROM PUBLIC';

-  ALTER DEFAULT PRIVILEGES FOR ROLE :"app_user" IN SCHEMA public
-    GRANT ALL PRIVILEGES ON TABLES TO :"app_user", :"dev_user";
-  ALTER DEFAULT PRIVILEGES FOR ROLE :"app_user" IN SCHEMA public
-    GRANT ALL PRIVILEGES ON SEQUENCES TO :"app_user", :"dev_user";
-  ALTER DEFAULT PRIVILEGES FOR ROLE :"app_user" IN SCHEMA public
-    GRANT EXECUTE ON FUNCTIONS TO :"app_user", :"dev_user";
+  FOR schema_record IN
+    SELECT nspname
+    FROM pg_namespace
+    WHERE nspname <> 'information_schema'
+      AND nspname NOT LIKE 'pg_%'
+  LOOP
+    EXECUTE format('REVOKE CREATE ON SCHEMA %I FROM PUBLIC', schema_record.nspname);
+    EXECUTE format('GRANT USAGE, CREATE ON SCHEMA %I TO %I', schema_record.nspname, dev_user);

-  ALTER DEFAULT PRIVILEGES FOR ROLE :"postgres_user" IN SCHEMA public
-    GRANT ALL PRIVILEGES ON TABLES TO :"app_user", :"dev_user";
-  ALTER DEFAULT PRIVILEGES FOR ROLE :"postgres_user" IN SCHEMA public
-    GRANT ALL PRIVILEGES ON SEQUENCES TO :"app_user", :"dev_user";
-  ALTER DEFAULT PRIVILEGES FOR ROLE :"postgres_user" IN SCHEMA public
-    GRANT EXECUTE ON FUNCTIONS TO :"app_user", :"dev_user";
+    EXECUTE format(
+      'GRANT SELECT, INSERT, UPDATE, DELETE, TRUNCATE, REFERENCES, TRIGGER, MAINTAIN ON ALL TABLES IN SCHEMA %I TO %I',
+      schema_record.nspname,
+      dev_user
+    );
+    EXECUTE format('GRANT USAGE, SELECT, UPDATE ON ALL SEQUENCES IN SCHEMA %I TO %I', schema_record.nspname, dev_user);
+    EXECUTE format('GRANT EXECUTE ON ALL ROUTINES IN SCHEMA %I TO %I', schema_record.nspname, dev_user);
+
+    EXECUTE format(
+      'ALTER DEFAULT PRIVILEGES FOR ROLE %I IN SCHEMA %I GRANT SELECT, INSERT, UPDATE, DELETE, TRUNCATE, REFERENCES, TRIGGER, MAINTAIN ON TABLES TO %I',
+      postgres_user,
+      schema_record.nspname,
+      dev_user
+    );
+    EXECUTE format(
+      'ALTER DEFAULT PRIVILEGES FOR ROLE %I IN SCHEMA %I GRANT USAGE, SELECT, UPDATE ON SEQUENCES TO %I',
+      postgres_user,
+      schema_record.nspname,
+      dev_user
+    );
+    EXECUTE format(
+      'ALTER DEFAULT PRIVILEGES FOR ROLE %I IN SCHEMA %I GRANT EXECUTE ON ROUTINES TO %I',
+      postgres_user,
+      schema_record.nspname,
+      dev_user
+    );
+
+    IF schema_record.nspname = app_schema THEN
+      EXECUTE format('ALTER SCHEMA %I OWNER TO %I', schema_record.nspname, app_user);
+
+      FOR rel_record IN
+        SELECT c.oid, c.relkind, n.nspname, c.relname
+        FROM pg_class c
+        JOIN pg_namespace n ON n.oid = c.relnamespace
+        LEFT JOIN pg_depend d ON d.objid = c.oid AND d.deptype = 'e'
+        WHERE n.nspname = schema_record.nspname
+          AND c.relkind IN ('r', 'p', 'v', 'm', 'f', 'S')
+          AND d.objid IS NULL
+      LOOP
+        IF rel_record.relkind IN ('r', 'p', 'f') THEN
+          EXECUTE format('ALTER TABLE %I.%I OWNER TO %I', rel_record.nspname, rel_record.relname, app_user);
+        ELSIF rel_record.relkind = 'v' THEN
+          EXECUTE format('ALTER VIEW %I.%I OWNER TO %I', rel_record.nspname, rel_record.relname, app_user);
+        ELSIF rel_record.relkind = 'm' THEN
+          EXECUTE format('ALTER MATERIALIZED VIEW %I.%I OWNER TO %I', rel_record.nspname, rel_record.relname, app_user);
+        ELSIF rel_record.relkind = 'S' THEN
+          EXECUTE format('ALTER SEQUENCE %I.%I OWNER TO %I', rel_record.nspname, rel_record.relname, app_user);
+        END IF;
+      END LOOP;
+
+      FOR routine_record IN
+        SELECT p.oid::regprocedure AS identity
+        FROM pg_proc p
+        JOIN pg_namespace n ON n.oid = p.pronamespace
+        LEFT JOIN pg_depend d ON d.objid = p.oid AND d.deptype = 'e'
+        WHERE n.nspname = schema_record.nspname
+          AND d.objid IS NULL
+      LOOP
+        EXECUTE format('ALTER ROUTINE %s OWNER TO %I', routine_record.identity, app_user);
+      END LOOP;
+
+      FOR type_record IN
+        SELECT format('%I.%I', n.nspname, t.typname) AS identity
+        FROM pg_type t
+        JOIN pg_namespace n ON n.oid = t.typnamespace
+        LEFT JOIN pg_depend d ON d.objid = t.oid AND d.deptype = 'e'
+        WHERE n.nspname = schema_record.nspname
+          AND t.typtype IN ('d', 'e')
+          AND d.objid IS NULL
+      LOOP
+        EXECUTE format('ALTER TYPE %s OWNER TO %I', type_record.identity, app_user);
+      END LOOP;
+
+      EXECUTE format('GRANT USAGE, CREATE ON SCHEMA %I TO %I', schema_record.nspname, app_user);
+
+      EXECUTE format('GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA %I TO %I', schema_record.nspname, app_user);
+      EXECUTE format('GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA %I TO %I', schema_record.nspname, app_user);
+      EXECUTE format('GRANT EXECUTE ON ALL ROUTINES IN SCHEMA %I TO %I', schema_record.nspname, app_user);
+
+      EXECUTE format(
+        'ALTER DEFAULT PRIVILEGES FOR ROLE %I IN SCHEMA %I GRANT ALL PRIVILEGES ON TABLES TO %I, %I',
+        app_user,
+        schema_record.nspname,
+        app_user,
+        dev_user
+      );
+      EXECUTE format(
+        'ALTER DEFAULT PRIVILEGES FOR ROLE %I IN SCHEMA %I GRANT ALL PRIVILEGES ON SEQUENCES TO %I, %I',
+        app_user,
+        schema_record.nspname,
+        app_user,
+        dev_user
+      );
+      EXECUTE format(
+        'ALTER DEFAULT PRIVILEGES FOR ROLE %I IN SCHEMA %I GRANT EXECUTE ON ROUTINES TO %I, %I',
+        app_user,
+        schema_record.nspname,
+        app_user,
+        dev_user
+      );
+
+      EXECUTE format(
+        'ALTER DEFAULT PRIVILEGES FOR ROLE %I IN SCHEMA %I GRANT ALL PRIVILEGES ON TABLES TO %I, %I',
+        postgres_user,
+        schema_record.nspname,
+        app_user,
+        dev_user
+      );
+      EXECUTE format(
+        'ALTER DEFAULT PRIVILEGES FOR ROLE %I IN SCHEMA %I GRANT ALL PRIVILEGES ON SEQUENCES TO %I, %I',
+        postgres_user,
+        schema_record.nspname,
+        app_user,
+        dev_user
+      );
+      EXECUTE format(
+        'ALTER DEFAULT PRIVILEGES FOR ROLE %I IN SCHEMA %I GRANT EXECUTE ON ROUTINES TO %I, %I',
+        postgres_user,
+        schema_record.nspname,
+        app_user,
+        dev_user
+      );
+    ELSE
+      IF EXISTS (
+        SELECT 1
+        FROM pg_namespace n
+        WHERE n.nspname = schema_record.nspname
+          AND pg_get_userbyid(n.nspowner) = app_user
+      ) THEN
+        EXECUTE format('ALTER SCHEMA %I OWNER TO %I', schema_record.nspname, postgres_user);
+      END IF;
+
+      FOR rel_record IN
+        SELECT c.oid, c.relkind, n.nspname, c.relname
+        FROM pg_class c
+        JOIN pg_namespace n ON n.oid = c.relnamespace
+        LEFT JOIN pg_depend d ON d.objid = c.oid AND d.deptype = 'e'
+        WHERE n.nspname = schema_record.nspname
+          AND c.relkind IN ('r', 'p', 'v', 'm', 'f', 'S')
+          AND d.objid IS NULL
+          AND pg_get_userbyid(c.relowner) = app_user
+      LOOP
+        IF rel_record.relkind IN ('r', 'p', 'f') THEN
+          EXECUTE format('ALTER TABLE %I.%I OWNER TO %I', rel_record.nspname, rel_record.relname, postgres_user);
+        ELSIF rel_record.relkind = 'v' THEN
+          EXECUTE format('ALTER VIEW %I.%I OWNER TO %I', rel_record.nspname, rel_record.relname, postgres_user);
+        ELSIF rel_record.relkind = 'm' THEN
+          EXECUTE format('ALTER MATERIALIZED VIEW %I.%I OWNER TO %I', rel_record.nspname, rel_record.relname, postgres_user);
+        ELSIF rel_record.relkind = 'S' THEN
+          EXECUTE format('ALTER SEQUENCE %I.%I OWNER TO %I', rel_record.nspname, rel_record.relname, postgres_user);
+        END IF;
+      END LOOP;
+
+      FOR routine_record IN
+        SELECT p.oid::regprocedure AS identity
+        FROM pg_proc p
+        JOIN pg_namespace n ON n.oid = p.pronamespace
+        LEFT JOIN pg_depend d ON d.objid = p.oid AND d.deptype = 'e'
+        WHERE n.nspname = schema_record.nspname
+          AND d.objid IS NULL
+          AND pg_get_userbyid(p.proowner) = app_user
+      LOOP
+        EXECUTE format('ALTER ROUTINE %s OWNER TO %I', routine_record.identity, postgres_user);
+      END LOOP;
+
+      FOR type_record IN
+        SELECT format('%I.%I', n.nspname, t.typname) AS identity
+        FROM pg_type t
+        JOIN pg_namespace n ON n.oid = t.typnamespace
+        LEFT JOIN pg_depend d ON d.objid = t.oid AND d.deptype = 'e'
+        WHERE n.nspname = schema_record.nspname
+          AND t.typtype IN ('d', 'e')
+          AND d.objid IS NULL
+          AND pg_get_userbyid(t.typowner) = app_user
+      LOOP
+        EXECUTE format('ALTER TYPE %s OWNER TO %I', type_record.identity, postgres_user);
+      END LOOP;
+
+      EXECUTE format(
+        'ALTER DEFAULT PRIVILEGES FOR ROLE %I IN SCHEMA %I REVOKE ALL PRIVILEGES ON TABLES FROM %I',
+        app_user,
+        schema_record.nspname,
+        app_user
+      );
+      EXECUTE format(
+        'ALTER DEFAULT PRIVILEGES FOR ROLE %I IN SCHEMA %I REVOKE ALL PRIVILEGES ON SEQUENCES FROM %I',
+        app_user,
+        schema_record.nspname,
+        app_user
+      );
+      EXECUTE format(
+        'ALTER DEFAULT PRIVILEGES FOR ROLE %I IN SCHEMA %I REVOKE ALL PRIVILEGES ON ROUTINES FROM %I',
+        app_user,
+        schema_record.nspname,
+        app_user
+      );
+      EXECUTE format(
+        'ALTER DEFAULT PRIVILEGES FOR ROLE %I IN SCHEMA %I REVOKE ALL PRIVILEGES ON TABLES FROM %I',
+        postgres_user,
+        schema_record.nspname,
+        app_user
+      );
+      EXECUTE format(
+        'ALTER DEFAULT PRIVILEGES FOR ROLE %I IN SCHEMA %I REVOKE ALL PRIVILEGES ON SEQUENCES FROM %I',
+        postgres_user,
+        schema_record.nspname,
+        app_user
+      );
+      EXECUTE format(
+        'ALTER DEFAULT PRIVILEGES FOR ROLE %I IN SCHEMA %I REVOKE ALL PRIVILEGES ON ROUTINES FROM %I',
+        postgres_user,
+        schema_record.nspname,
+        app_user
+      );
+
+      EXECUTE format('REVOKE ALL PRIVILEGES ON SCHEMA %I FROM %I', schema_record.nspname, app_user);
+      EXECUTE format('REVOKE ALL PRIVILEGES ON ALL TABLES IN SCHEMA %I FROM %I', schema_record.nspname, app_user);
+      EXECUTE format('REVOKE ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA %I FROM %I', schema_record.nspname, app_user);
+      EXECUTE format('REVOKE ALL PRIVILEGES ON ALL ROUTINES IN SCHEMA %I FROM %I', schema_record.nspname, app_user);
+    END IF;
+  END LOOP;
+END
+$do$;
 SQL
 else
   log "Skipping schema grants because database ${MEDUSA_APP_DB_NAME} does not exist"
 fi

+log "Applying dev grants across all non-template databases and schemas"
+db_list="$(psql --username "$POSTGRES_USER" --dbname postgres -tA -c "SELECT datname FROM pg_database WHERE datistemplate = false ORDER BY datname")"
+printf '%s\n' "$db_list" | while IFS= read -r db_name; do
+  [ -n "$db_name" ] || continue
+  psql --username "$POSTGRES_USER" --dbname "$db_name" \
+    -v ON_ERROR_STOP=1 \
+    -v dev_user="$MEDUSA_DEV_DB_USER" <<'SQL'
+SELECT set_config('zane.dev_user', :'dev_user', false);
+
+DO $do$
+DECLARE
+  dev_user text := current_setting('zane.dev_user');
+  schema_record RECORD;
+BEGIN
+  FOR schema_record IN
+    SELECT nspname
+    FROM pg_namespace
+    WHERE nspname <> 'information_schema'
+      AND nspname NOT LIKE 'pg_%'
+  LOOP
+    EXECUTE format('GRANT USAGE ON SCHEMA %I TO %I', schema_record.nspname, dev_user);
+    EXECUTE format(
+      'GRANT SELECT, INSERT, UPDATE, DELETE, TRUNCATE, REFERENCES, TRIGGER, MAINTAIN ON ALL TABLES IN SCHEMA %I TO %I',
+      schema_record.nspname,
+      dev_user
+    );
+    EXECUTE format(
+      'GRANT USAGE, SELECT, UPDATE ON ALL SEQUENCES IN SCHEMA %I TO %I',
+      schema_record.nspname,
+      dev_user
+    );
+    EXECUTE format('GRANT EXECUTE ON ALL ROUTINES IN SCHEMA %I TO %I', schema_record.nspname, dev_user);
+  END LOOP;
+END
+$do$;
+SQL
+done
+
 log "Role bootstrap complete"
diff --git c/scripts/apply-postgres-role-bootstrap.sh i/scripts/apply-postgres-role-bootstrap.sh
index 72553ed3..a7f7b2b3 100644
--- c/scripts/apply-postgres-role-bootstrap.sh
+++ i/scripts/apply-postgres-role-bootstrap.sh
@@ -6,7 +6,12 @@ if ! command -v docker >/dev/null 2>&1; then
   exit 1
 fi

-echo "Applying postgres role bootstrap to running medusa-db container..."
+echo "Applying postgres role bootstrap (medusa_app/medusa_dev) to running medusa-db container..."
 docker compose exec -T medusa-db /docker-entrypoint-initdb.d/01-zane-role-bootstrap.sh

+if [ "${1:-}" = "--verify-idempotent" ]; then
+  echo "Running bootstrap second time to verify idempotency..."
+  docker compose exec -T medusa-db /docker-entrypoint-initdb.d/01-zane-role-bootstrap.sh
+fi
+
 echo "Done."
…cache points, better overrides for volumes, typo in .env.docker fix
@redeyecz
redeyecz force-pushed the feat/zaneops_db_http branch from 07b0286 to 9feb6cc Compare February 25, 2026 16:10
@redeyecz
redeyecz merged commit 5091625 into master Feb 25, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant