{"id":50993224,"url":"https://github.com/mhbdev/drizzle-trpc-shield","last_synced_at":"2026-06-20T05:32:29.781Z","repository":{"id":364746557,"uuid":"1268392033","full_name":"mhbdev/drizzle-trpc-shield","owner":"mhbdev","description":null,"archived":false,"fork":false,"pushed_at":"2026-06-14T09:17:37.000Z","size":89,"stargazers_count":0,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"master","last_synced_at":"2026-06-14T11:12:51.643Z","etag":null,"topics":[],"latest_commit_sha":null,"homepage":null,"language":"TypeScript","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/mhbdev.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-06-13T13:30:13.000Z","updated_at":"2026-06-14T09:17:40.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/mhbdev/drizzle-trpc-shield","commit_stats":null,"previous_names":["mhbdev/drizzle-trpc-shield"],"tags_count":null,"template":false,"template_full_name":null,"purl":"pkg:github/mhbdev/drizzle-trpc-shield","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/mhbdev%2Fdrizzle-trpc-shield","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/mhbdev%2Fdrizzle-trpc-shield/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/mhbdev%2Fdrizzle-trpc-shield/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/mhbdev%2Fdrizzle-trpc-shield/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/mhbdev","download_url":"https://codeload.github.com/mhbdev/drizzle-trpc-shield/tar.gz/refs/heads/master","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/mhbdev%2Fdrizzle-trpc-shield/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":34558894,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-06-20T02:00:06.407Z","response_time":98,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":[],"created_at":"2026-06-20T05:32:29.069Z","updated_at":"2026-06-20T05:32:29.775Z","avatar_url":"https://github.com/mhbdev.png","language":"TypeScript","funding_links":[],"categories":[],"sub_categories":[],"readme":"# drizzle-trpc-shield\n\nA plug-in layer that turns Drizzle ORM tables into secure, ready-to-use tRPC APIs automatically.\n\n`drizzle-trpc-shield` is built for teams that want generated CRUD without giving up explicit authorization, field-level security, lifecycle hooks, plugin extension points, or TypeScript inference.\n\n## What it gives you\n\n- `defineTable` to wrap a Drizzle table with resource-level API config\n- `defineResource` for a fluent, developer-friendly resource builder\n- `createShieldRouter` for a plug-and-play router from raw tables or resource definitions\n- `createDbRouter` for the direct table-map API\n- `createShield` when you want the full shield object, router map, resource map, and contract\n- `ApiContext` for typed request context flowing through policies, guards, hooks, and plugins\n- `allow`, `deny`, `policy`, and guard helpers for row-level access control\n- field visibility controls with `hidden`, `readonly`, `writable`, `select`, and `columnPolicies`\n- safe query controls for filterable columns, sortable columns, limits, offsets, and cursor pagination\n- generated procedures: `list`, `findMany`, `get`, `findById`, `create`, `createMany`, `update`, `delete`, and `deleteMany`\n- lifecycle hooks and plugins for auditing, transforms, tenant injection, custom behavior, and side effects\n- strict fail-closed defaults: enabled operations must have a global, resource, or operation policy\n\n## Install\n\n```bash\npnpm add drizzle-trpc-shield @trpc/server drizzle-orm zod\n```\n\nThe package is ESM-first, ships CommonJS output, and expects Node.js 20 or newer.\n\n## Quick Start\n\n```ts\nimport { initTRPC } from \"@trpc/server\";\nimport { integer, sqliteTable, text } from \"drizzle-orm/sqlite-core\";\nimport {\n  allow,\n  createDbRouter,\n  defineTable,\n  policy,\n  type ApiContext,\n} from \"drizzle-trpc-shield\";\n\ntype Context = ApiContext\u003c{\n  user?: {\n    id: number;\n    role?: \"admin\" | \"member\";\n  };\n}\u003e;\n\nconst t = initTRPC.context\u003cContext\u003e().create();\n\nconst users = sqliteTable(\"users\", {\n  id: integer(\"id\").primaryKey({ autoIncrement: true }),\n  name: text(\"name\").notNull(),\n  email: text(\"email\").notNull(),\n  passwordHash: text(\"password_hash\").notNull(),\n});\n\nexport const appRouter = createDbRouter({\n  db,\n  trpc: t,\n  tables: {\n    users: defineTable(users, {\n      policy: policy\u003cContext\u003e()({\n        all: allow.authenticated(),\n      }),\n      fields: {\n        hidden: [\"passwordHash\"],\n        readonly: [\"id\"],\n      },\n      query: {\n        filterable: [\"email\", \"name\"],\n        sortable: [\"id\", \"name\"],\n        defaultLimit: 20,\n        maxLimit: 100,\n      },\n      operations: {\n        list: true,\n        get: true,\n        create: true,\n        createMany: true,\n        update: true,\n        delete: true,\n        deleteMany: true,\n      },\n    }),\n  },\n});\n\nexport type AppRouter = typeof appRouter;\n```\n\n```ts\nconst caller = t.createCallerFactory(appRouter)({\n  user: { id: 1, role: \"admin\" },\n});\n\nconst page = await caller.users.findMany({\n  filters: {\n    email: { op: \"contains\", value: \"@acme.com\" },\n  },\n  sort: [{ column: \"id\", direction: \"desc\" }],\n  pagination: { page: 1, limit: 20 },\n});\n```\n\n## Plug-And-Play Router\n\nIf you already have a table map and want the shortest route to an API, use `createShieldRouter`. Raw resources are normalized into `defineTable(...)` resources for you.\n\n```ts\nimport { createShieldRouter, contextGuard } from \"drizzle-trpc-shield\";\n\nconst isSignedIn = contextGuard\u003cContext\u003e((ctx) =\u003e Boolean(ctx.user));\n\nexport const appRouter = createShieldRouter({\n  db,\n  t,\n  config: {\n    globalGuards: [isSignedIn],\n    resources: {\n      users: {\n        table: users,\n        fields: {\n          hidden: [\"passwordHash\"],\n          readonly: [\"id\"],\n        },\n        query: {\n          filterable: [\"email\", \"name\"],\n          sortable: [\"id\"],\n        },\n      },\n    },\n  },\n});\n```\n\n`createShieldRouter` is useful when integrating into existing apps or migrating from a hand-written router. It still keeps the same security model: if no global, resource, or operation policy exists, router creation fails unless you explicitly opt out with `security.requirePolicies: false`.\n\n## Generated Procedures\n\n| Procedure | Kind | Input shape | Notes |\n| --- | --- | --- | --- |\n| `list` | query | `{ where?, orderBy?, limit?, offset? }` | canonical list procedure |\n| `findMany` | query | `{ filters?, sort?, pagination? }` | ergonomic alias for `list` |\n| `get` | query | primary key input | canonical single-row procedure |\n| `findById` | query | primary key input | ergonomic alias for `get` |\n| `create` | mutation | writable insert data | strips hidden, readonly, and non-writable fields |\n| `createMany` | mutation | `{ data: [...] }` | bulk create with the same write protection |\n| `update` | mutation | primary key plus writable data | protects readonly and non-writable fields |\n| `delete` | mutation | primary key input | returns the deleted visible row |\n| `deleteMany` | mutation | `{ where }` or `{ filters }` | requires at least one filter |\n\nThe type of each generated procedure is inferred from the Drizzle table, resource options, field visibility, transforms, and enabled operations.\n\n## Core Building Blocks\n\n### `defineTable`\n\nUse `defineTable` when you want the strictest, most explicit type inference:\n\n```ts\nconst usersResource = defineTable(users, {\n  name: \"users\",\n  policy: { all: allow.authenticated() },\n  fields: {\n    hidden: [\"passwordHash\"],\n    readonly: [\"id\", \"createdAt\", \"updatedAt\"],\n    select: [\"id\", \"name\", \"email\", \"createdAt\"],\n  },\n  columnPolicies: {\n    passwordHash: {\n      readable: false,\n      writable: false,\n      filterable: false,\n      sortable: false,\n    },\n    role: {\n      writable: false,\n    },\n  },\n  query: {\n    filterable: [\"email\", \"name\", \"role\"],\n    sortable: [\"id\", \"name\", \"createdAt\"],\n    defaultLimit: 25,\n    maxLimit: 100,\n  },\n  operations: {\n    list: true,\n    get: true,\n    create: true,\n    update: true,\n  },\n});\n```\n\nUse this for production resources where you want the configuration to read like an API contract.\n\n### `defineResource`\n\nUse `defineResource` when you prefer a fluent API:\n\n```ts\nimport {\n  defineResource,\n  hasRole,\n  injectField,\n  scopeToTenant,\n  toISOString,\n} from \"drizzle-trpc-shield\";\n\nconst postsResource = defineResource\u003ctypeof posts, Context\u003e(posts)\n  .operations(\"findMany\", \"findById\", \"create\", \"update\", \"delete\")\n  .guards(scopeToTenant\u003cContext, typeof posts\u003e(\"tenantId\", (ctx) =\u003e ctx.user?.tenantId))\n  .operationGuards(\"delete\", hasRole\u003cContext\u003e((ctx) =\u003e ctx.user?.role, \"admin\"))\n  .columnPolicy(\"tenantId\", { writable: false, filterable: false })\n  .beforeQuery(\"create\", injectField(\"tenantId\", (ctx) =\u003e ctx.user?.tenantId))\n  .transform(\"createdAt\", toISOString)\n  .defaultSelect(\"id\", \"tenantId\", \"title\", \"createdAt\")\n  .pagination({\n    mode: \"cursor\",\n    cursorColumn: \"id\",\n    defaultLimit: 20,\n    maxLimit: 100,\n  })\n  .build();\n```\n\n`defineTable` is best for exact literal config inference. `defineResource` is best for progressive setup and discoverable DX.\n\n### `ApiContext`\n\nDefine request context once, then use it everywhere:\n\n```ts\ntype Context = ApiContext\u003c{\n  user?: {\n    id: string;\n    tenantId: string;\n    role: \"owner\" | \"admin\" | \"member\";\n    permissions: string[];\n  };\n  req: Request;\n}\u003e;\n```\n\nPolicies, guard helpers, hook handlers, and plugins all receive this typed context.\n\n### Policies And Guards\n\nYou can use the low-level policy helpers:\n\n```ts\ndefineTable(posts, {\n  policy: policy\u003cContext\u003e()({\n    all: allow.authenticated(),\n    before: {\n      list: allow.scope(({ ctx }) =\u003e eq(posts.tenantId, ctx.user!.tenantId)),\n      update: allow.role(\"admin\", (ctx) =\u003e ctx.user?.role),\n    },\n  }),\n});\n```\n\nOr use guard helpers when you want reusable rules:\n\n```ts\nimport {\n  and,\n  contextGuard,\n  hasPermission,\n  hasRole,\n  readOnly,\n} from \"drizzle-trpc-shield\";\n\nconst isSignedIn = contextGuard\u003cContext\u003e((ctx) =\u003e Boolean(ctx.user));\nconst canManageUsers = hasPermission\u003cContext\u003e(\n  (ctx) =\u003e ctx.user?.permissions,\n  \"users:manage\",\n);\n\nconst usersResource = defineTable(users, {\n  policy: {\n    all: isSignedIn,\n    before: {\n      list: readOnly(),\n      update: and(hasRole\u003cContext\u003e((ctx) =\u003e ctx.user?.role, [\"owner\", \"admin\"]), canManageUsers),\n    },\n  },\n});\n```\n\nAvailable helpers include `contextGuard`, `hasRole`, `hasPermission`, `and`, `or`, `not`, `readOnly`, `scopeToTenant`, and `injectField`.\n\n### Field Security\n\nThere are two layers of field control:\n\n```ts\ndefineTable(users, {\n  policy: { all: allow.authenticated() },\n  fields: {\n    hidden: [\"passwordHash\"],\n    readonly: [\"id\", \"createdAt\"],\n    writable: [\"name\", \"email\"],\n    select: [\"id\", \"name\", \"email\", \"createdAt\"],\n  },\n  columnPolicies: {\n    passwordHash: {\n      readable: false,\n      writable: false,\n      filterable: false,\n      sortable: false,\n    },\n    emailVerifiedAt: {\n      writable: false,\n    },\n  },\n});\n```\n\n`fields` is the resource-level API shape. `columnPolicies` is the stricter per-column security layer. If a column is not readable, it is removed from output and cannot be filtered or sorted. If it is not writable, client input cannot set it.\n\n### Querying\n\nCanonical list input:\n\n```ts\nawait caller.users.list({\n  where: {\n    email: { contains: \"@acme.com\" },\n    createdAt: { between: [from, to] },\n  },\n  orderBy: [{ field: \"id\", direction: \"desc\" }],\n  limit: 50,\n  offset: 0,\n});\n```\n\nErgonomic alias input:\n\n```ts\nawait caller.users.findMany({\n  filters: {\n    email: { op: \"contains\", value: \"@acme.com\" },\n    status: { op: \"in\", values: [\"active\", \"invited\"] },\n  },\n  sort: [{ column: \"createdAt\", direction: \"desc\" }],\n  pagination: { page: 1, limit: 25 },\n});\n```\n\nSupported filter operators include `eq`, `ne`, `neq`, `in`, `notIn`, `isNull`, `isNotNull`, `gt`, `gte`, `lt`, `lte`, `between`, `like`, `ilike`, `contains`, `startsWith`, and `endsWith`.\n\n### Transforms\n\nTransforms run before data leaves the generated API:\n\n```ts\nimport { parseJSON, redact, toISOString, trimString } from \"drizzle-trpc-shield\";\n\ndefineTable(users, {\n  policy: { all: allow.authenticated() },\n  transforms: {\n    name: trimString,\n    metadata: parseJSON,\n    createdAt: toISOString,\n    passwordHash: redact,\n  },\n  columnPolicies: {\n    passwordHash: { readable: false, writable: false },\n  },\n});\n```\n\nUse transforms for serialized dates, JSON text columns, display normalization, and defensive redaction.\n\n### Hooks And Plugins\n\nHooks can observe or transform input and output around generated operations:\n\n```ts\nimport type { ShieldPlugin } from \"drizzle-trpc-shield\";\n\nconst auditPlugin: ShieldPlugin\u003cContext\u003e = {\n  name: \"audit\",\n  hooks: {\n    beforeCreate({ ctx, resourceName, input }) {\n      console.log(\"create\", resourceName, ctx.user?.id, input);\n      return input;\n    },\n    afterUpdate({ ctx, resourceName, result }) {\n      console.log(\"update\", resourceName, ctx.user?.id, result);\n      return result;\n    },\n  },\n};\n\nconst appRouter = createDbRouter({\n  db,\n  trpc: t,\n  plugins: [auditPlugin],\n  tables: {\n    users: defineTable(users, { policy: { all: allow.authenticated() } }),\n  },\n});\n```\n\nResource-level plugins are also supported:\n\n```ts\ndefineTable(users, {\n  policy: { all: allow.authenticated() },\n  plugins: [auditPlugin],\n});\n```\n\n### Logging Hooks\n\nFor simple structured logs:\n\n```ts\nimport { createLoggingHooks, type ShieldPlugin } from \"drizzle-trpc-shield\";\n\nconst loggingPlugin: ShieldPlugin\u003cContext\u003e = {\n  name: \"logging\",\n  hooks: createLoggingHooks((entry) =\u003e {\n    console.log(entry.resource, entry.operation, entry.durationMs);\n  }),\n};\n```\n\n## Practical Recipes\n\n### Secure Admin Users API\n\n```ts\nconst usersResource = defineTable(users, {\n  policy: policy\u003cContext\u003e()({\n    all: allow.authenticated(),\n    before: {\n      create: allow.role(\"admin\", (ctx) =\u003e ctx.user?.role),\n      update: allow.role(\"admin\", (ctx) =\u003e ctx.user?.role),\n      delete: allow.role(\"owner\", (ctx) =\u003e ctx.user?.role),\n      deleteMany: allow.role(\"owner\", (ctx) =\u003e ctx.user?.role),\n    },\n  }),\n  fields: {\n    hidden: [\"passwordHash\", \"resetToken\"],\n    readonly: [\"id\", \"createdAt\", \"updatedAt\"],\n  },\n  columnPolicies: {\n    passwordHash: { readable: false, writable: false, filterable: false, sortable: false },\n    resetToken: { readable: false, writable: false, filterable: false, sortable: false },\n  },\n  query: {\n    filterable: [\"email\", \"role\"],\n    sortable: [\"id\", \"email\", \"createdAt\"],\n    defaultLimit: 25,\n    maxLimit: 100,\n  },\n  operations: {\n    list: true,\n    get: true,\n    create: true,\n    createMany: false,\n    update: true,\n    delete: true,\n    deleteMany: true,\n  },\n});\n```\n\nThis gives you an admin-ready router while keeping secrets out of output, filters, sorts, and client writes.\n\n### Multi-Tenant SaaS Resource\n\n```ts\nconst projectsResource = defineResource\u003ctypeof projects, Context\u003e(projects)\n  .guards(\n    contextGuard\u003cContext\u003e((ctx) =\u003e Boolean(ctx.user)),\n    scopeToTenant\u003cContext, typeof projects\u003e(\"tenantId\", (ctx) =\u003e ctx.user?.tenantId),\n  )\n  .columnPolicy(\"tenantId\", { writable: false, filterable: false })\n  .beforeQuery(\"create\", injectField(\"tenantId\", (ctx) =\u003e ctx.user?.tenantId))\n  .beforeQuery(\"createMany\", injectField(\"tenantId\", (ctx) =\u003e ctx.user?.tenantId))\n  .operations(\"findMany\", \"findById\", \"create\", \"createMany\", \"update\", \"delete\")\n  .build();\n```\n\nThe client never sends `tenantId`; the server injects it after validation, and row-level scopes keep every query inside the caller's tenant.\n\n### Bulk Import\n\n```ts\nawait caller.users.createMany({\n  data: [\n    { name: \"Ada\", email: \"ada@acme.com\" },\n    { name: \"Grace\", email: \"grace@acme.com\" },\n  ],\n});\n```\n\n`createMany` uses the same writable-column rules, policies, hooks, transforms, and output masking as `create`.\n\n### Safe Bulk Cleanup\n\n```ts\nawait caller.users.deleteMany({\n  filters: {\n    email: { op: \"endsWith\", value: \"@example.test\" },\n  },\n});\n```\n\n`deleteMany` requires at least one filter, only accepts filterable columns, and still applies row-level policy scopes.\n\n### Custom Write Path\n\n```ts\ndefineTable(users, {\n  policy: { create: allow.authenticated() },\n  operations: {\n    create: {\n      execute: async ({ db, table, input }) =\u003e {\n        const [row] = await db.insert(table).values(input).returning();\n        return row;\n      },\n    },\n  },\n});\n```\n\nUse custom operation executors when a resource needs special joins, driver-specific behavior, database functions, or a non-standard mutation flow.\n\n## Real-World Use Cases\n\n- Admin dashboards with generated CRUD and no hand-written router boilerplate\n- Multi-tenant SaaS apps where every query must be tenant-scoped\n- Internal tools that move quickly while hiding sensitive columns\n- BFF layers for web and mobile clients that need one typed API per table\n- Audit-heavy workflows that need consistent lifecycle hooks\n- Data import screens with guarded `createMany`\n- Moderation tools with safe, filtered `deleteMany`\n- Prototypes that should keep production-shaped security from day one\n\n## Architecture Map\n\nThe current architecture supports the system model:\n\n- `defineTable` wraps a Drizzle schema with per-table config\n- `defineResource` adds fluent resource composition for DX\n- `createDbRouter` turns table definitions into a tRPC router\n- `createShieldRouter` accepts raw resource configs and emits the same generated API\n- `ApiContext` is the typed request context for policies, hooks, plugins, and guards\n- access policies are composable through `allow`, `deny`, `policy`, and guard helpers\n- row-level access is expressed as SQL scopes returned by policies and guards\n- field-level security is enforced by `fields` and `columnPolicies`\n- lifecycle hooks cover CRUD and bulk operations\n- plugins can observe resource init, transform input/output, and attach side effects\n- validation is adapter-based, with a Zod adapter included by default\n\n## Security Notes\n\n- Access is fail-closed by default. If an operation is enabled, it must have a policy from the global, resource, or operation layer.\n- Hidden and unreadable fields are removed from output.\n- Readonly and non-writable fields are stripped from client writes.\n- Filters and sorts are allow-listed. A column must be explicitly filterable or sortable before the generated API accepts it.\n- Bulk deletes require a filter object.\n- Server-side hooks can inject trusted fields after validation, which is useful for tenant IDs, owner IDs, and audit columns.\n\n## Package Scripts\n\n```bash\npnpm lint\npnpm typecheck\npnpm test:type\npnpm test\npnpm build\npnpm check\npnpm publint\npnpm attw --pack .\n```\n\n## Notes\n\n- If your database driver does not support `returning()`, provide a custom `execute` handler for that operation.\n- `defineTable` gives the tightest literal config inference; `defineResource` gives a more fluent authoring experience.\n- The package is designed to stay explicit: no implicit access, no unbounded filters, and no lost TypeScript inference.\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fmhbdev%2Fdrizzle-trpc-shield","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fmhbdev%2Fdrizzle-trpc-shield","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fmhbdev%2Fdrizzle-trpc-shield/lists"}