@pablo2410/core-server 0.15.1 → 0.15.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -10,6 +10,15 @@ interface IsolationUser {
10
10
  }
11
11
  /** What a read-only account is told when it tries to change something. */
12
12
  declare const READ_ONLY_ERR_MSG = "This account is read-only: it can look but not change anything.";
13
+ /**
14
+ * The only mutation a read-only account may call (core-server#129): signing
15
+ * itself out, `auth.logout` in every app. It ends the caller's own session and
16
+ * changes nothing else, so the live check's login can sign out after each run
17
+ * instead of leaving its session open. Matched on the full procedure path, so
18
+ * any other mutation, even one named "logout" elsewhere (`admin.logout`,
19
+ * `auth.logoutAll`), is still refused.
20
+ */
21
+ declare const READ_ONLY_ALLOWED_MUTATIONS: readonly string[];
13
22
  /** The context fields isolation needs. Each app's tRPC context must have these. */
14
23
  interface IsolationContext<TUser extends IsolationUser = IsolationUser> {
15
24
  user: TUser | null;
@@ -101,5 +110,5 @@ type IsolationProcedures<TUser extends IsolationUser = IsolationUser> = ReturnTy
101
110
  */
102
111
  declare function refuseReadOnlyWritesExpress(getUser: (req: Request) => Promise<IsolationUser | null | undefined>): (req: Request, res: Response, next: NextFunction) => Promise<void | Response<any, Record<string, any>>>;
103
112
 
104
- export { ADMIN_MIN_ROLE, READ_ONLY_ERR_MSG, assertEnterpriseOwnership, createIsolationProcedures, isAdminRole, refuseReadOnlyWritesExpress, requireEnterpriseId, resolveEnterpriseId, scopeToEnterprise };
113
+ export { ADMIN_MIN_ROLE, READ_ONLY_ALLOWED_MUTATIONS, READ_ONLY_ERR_MSG, assertEnterpriseOwnership, createIsolationProcedures, isAdminRole, refuseReadOnlyWritesExpress, requireEnterpriseId, resolveEnterpriseId, scopeToEnterprise };
105
114
  export type { EnterpriseOwned, IsolationContext, IsolationProcedures, IsolationUser, OwnershipMessages };
package/dist/isolation.js CHANGED
@@ -8,6 +8,9 @@ import {
8
8
  UNAUTHED_ERR_MSG
9
9
  } from "@pablo2410/shared-ui/rbac";
10
10
  var READ_ONLY_ERR_MSG = "This account is read-only: it can look but not change anything.";
11
+ var READ_ONLY_ALLOWED_MUTATIONS = Object.freeze([
12
+ "auth.logout"
13
+ ]);
11
14
  function isEnterpriseId(value) {
12
15
  return typeof value === "number" && Number.isInteger(value) && value > 0;
13
16
  }
@@ -83,8 +86,8 @@ function createIsolationProcedures() {
83
86
  });
84
87
  }
85
88
  const requireAdmin = requireRole(ADMIN_MIN_ROLE, NOT_ADMIN_ERR_MSG);
86
- const refuseReadOnlyWrites = t.middleware(({ ctx, type, next }) => {
87
- if (type === "mutation" && ctx.user?.readOnly === true) {
89
+ const refuseReadOnlyWrites = t.middleware(({ ctx, type, path, next }) => {
90
+ if (type === "mutation" && ctx.user?.readOnly === true && !READ_ONLY_ALLOWED_MUTATIONS.includes(path)) {
88
91
  throw new TRPCError({ code: "FORBIDDEN", message: READ_ONLY_ERR_MSG });
89
92
  }
90
93
  return next();
@@ -125,6 +128,7 @@ function refuseReadOnlyWritesExpress(getUser) {
125
128
  }
126
129
  export {
127
130
  ADMIN_MIN_ROLE,
131
+ READ_ONLY_ALLOWED_MUTATIONS,
128
132
  READ_ONLY_ERR_MSG,
129
133
  assertEnterpriseOwnership,
130
134
  createIsolationProcedures,
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/isolation.ts"],"sourcesContent":["/**\n * Enterprise Isolation — the one enforced tenant-scoping path for every service.\n *\n * Replaces the per-app copies of `requireEnterprise` / `enterpriseProcedure` and\n * the hand-written `rows.filter(r => r.enterpriseId === id)` and \"does this\n * record belong to the caller?\" checks. Roles and error messages come from\n * `@pablo2410/shared-ui/rbac`, the platform's single role model.\n *\n * Every check fails closed: no user → UNAUTHORIZED; no (valid) enterprise →\n * FORBIDDEN / no rows; an unknown role never passes a role check; a record with\n * no enterpriseId never matches, even against a caller with none.\n *\n * Quick example (each app keeps its own `t`; the procedures are concatenated\n * onto it, so the app's transformer, context and error formatter still apply):\n *\n * import { createIsolationProcedures } from \"@pablo2410/core-server/isolation\";\n * const iso = createIsolationProcedures<SessionUser>();\n * export const enterpriseProcedure = t.procedure.concat(iso.enterpriseProcedure);\n * // in a resolver: ctx.enterpriseId is a number, ctx.user is non-null\n * listPlans: enterpriseProcedure.query(async ({ ctx }) =>\n * scopeToEnterprise(await getPlans(), ctx.enterpriseId)),\n */\nimport type { NextFunction, Request, Response } from \"express\";\nimport { initTRPC, TRPCError } from \"@trpc/server\";\nimport {\n NO_ENTERPRISE_ERR_MSG,\n NOT_ADMIN_ERR_MSG,\n NOT_PLATFORM_ADMIN_ERR_MSG,\n ROLE_HIERARCHY,\n UNAUTHED_ERR_MSG,\n type RoleType,\n} from \"@pablo2410/shared-ui/rbac\";\n\n/** The user fields isolation needs. Each app's session user must have these. */\nexport interface IsolationUser {\n role: string;\n enterpriseId?: number | null;\n /** True for a read-only account (ops#246): every write from it is refused. */\n readOnly?: boolean;\n}\n\n/** What a read-only account is told when it tries to change something. */\nexport const READ_ONLY_ERR_MSG =\n \"This account is read-only: it can look but not change anything.\";\n\n/** The context fields isolation needs. Each app's tRPC context must have these. */\nexport interface IsolationContext<TUser extends IsolationUser = IsolationUser> {\n user: TUser | null;\n}\n\n/** Anything owned by one enterprise (a row from the DB or the Portal API). */\nexport interface EnterpriseOwned {\n enterpriseId?: number | null;\n}\n\n/** Only positive integers are real enterprise IDs. */\nfunction isEnterpriseId(value: unknown): value is number {\n return typeof value === \"number\" && Number.isInteger(value) && value > 0;\n}\n\n/**\n * True when `userRole` is a known role at or above `minimumRole`.\n * Unlike a bare indexOf comparison, an unknown role on either side is never\n * treated as meeting the bar.\n */\nfunction meetsMinimumRole(\n userRole: string | null | undefined,\n minimumRole: RoleType\n): boolean {\n const user = ROLE_HIERARCHY.indexOf(userRole as RoleType);\n const min = ROLE_HIERARCHY.indexOf(minimumRole);\n return user >= 0 && min >= 0 && user >= min;\n}\n\n/**\n * The fleet's one admin rule: enterprise_admin and above (enterprise_admin,\n * platform_admin). Paul, 5 Oct 2026 (core-server#74). Every admin check in\n * every app uses this; no app sets its own admin threshold, and superuser is\n * not an admin level.\n */\nexport const ADMIN_MIN_ROLE = \"enterprise_admin\" as const satisfies RoleType;\n\n/** True when `role` passes the fleet admin rule (ADMIN_MIN_ROLE and above). */\nexport function isAdminRole(role: string | null | undefined): boolean {\n return meetsMinimumRole(role, ADMIN_MIN_ROLE);\n}\n\n/**\n * The caller's enterprise, or null. Never a default tenant: a missing,\n * zero, negative or non-integer enterpriseId is null.\n */\nexport function resolveEnterpriseId(\n user: IsolationUser | null | undefined\n): number | null {\n const id = user?.enterpriseId;\n return isEnterpriseId(id) ? id : null;\n}\n\n/** Like resolveEnterpriseId, but throws UNAUTHORIZED / FORBIDDEN instead of null. */\nexport function requireEnterpriseId(\n user: IsolationUser | null | undefined\n): number {\n if (!user) {\n throw new TRPCError({ code: \"UNAUTHORIZED\", message: UNAUTHED_ERR_MSG });\n }\n const id = resolveEnterpriseId(user);\n if (id === null) {\n throw new TRPCError({ code: \"FORBIDDEN\", message: NO_ENTERPRISE_ERR_MSG });\n }\n return id;\n}\n\n/**\n * Keep only the rows owned by `enterpriseId`. A null/invalid enterprise gets no\n * rows; a row with no enterpriseId is never included.\n */\nexport function scopeToEnterprise<T extends EnterpriseOwned>(\n rows: readonly T[],\n enterpriseId: number | null | undefined\n): T[] {\n if (!isEnterpriseId(enterpriseId)) return [];\n return rows.filter(row => row.enterpriseId === enterpriseId);\n}\n\nexport interface OwnershipMessages {\n /** Message when the record does not exist. Default \"Not found\". */\n notFound?: string;\n /** Message when the record belongs to another enterprise. */\n forbidden?: string;\n}\n\n/**\n * Return `record` if it belongs to `enterpriseId`; otherwise throw.\n * Missing record → NOT_FOUND. Other enterprise, a record with no enterpriseId,\n * or a caller with no enterprise → FORBIDDEN.\n */\nexport function assertEnterpriseOwnership<T extends EnterpriseOwned>(\n record: T | null | undefined,\n enterpriseId: number | null | undefined,\n messages: OwnershipMessages = {}\n): T {\n if (record === null || record === undefined) {\n throw new TRPCError({\n code: \"NOT_FOUND\",\n message: messages.notFound ?? \"Not found\",\n });\n }\n if (!isEnterpriseId(enterpriseId) || record.enterpriseId !== enterpriseId) {\n throw new TRPCError({\n code: \"FORBIDDEN\",\n message:\n messages.forbidden ??\n \"Access denied: this record belongs to another enterprise\",\n });\n }\n return record;\n}\n\n/**\n * Build the standard procedures for an app whose session user is `TUser`.\n * Attach each one to the app's own tRPC instance with `t.procedure.concat(...)`.\n *\n * | Procedure | Auth | Enterprise | Min role |\n * |--------------------------|:----:|:----------:|-----------------------------|\n * | protectedProcedure | Yes | No | any |\n * | enterpriseProcedure | Yes | Yes | any |\n * | adminProcedure | Yes | No | ADMIN_MIN_ROLE (admin gate) |\n * | enterpriseAdminProcedure | Yes | Yes | ADMIN_MIN_ROLE (admin gate) |\n * | platformAdminProcedure | Yes | No | platform_admin |\n *\n * Both admin procedures share one admin gate built from ADMIN_MIN_ROLE.\n * Enterprise procedures add `ctx.enterpriseId: number` and narrow `ctx.user`\n * to non-null. Checks run in order: user, then enterprise, then role.\n */\nexport function createIsolationProcedures<\n TUser extends IsolationUser = IsolationUser,\n>() {\n const t = initTRPC.context<IsolationContext<TUser>>().create();\n\n const requireUser = t.middleware(({ ctx, next }) => {\n if (!ctx.user) {\n throw new TRPCError({ code: \"UNAUTHORIZED\", message: UNAUTHED_ERR_MSG });\n }\n return next({ ctx: { user: ctx.user } });\n });\n\n const requireEnterprise = t.middleware(({ ctx, next }) => {\n const enterpriseId = requireEnterpriseId(ctx.user);\n return next({ ctx: { user: ctx.user as TUser, enterpriseId } });\n });\n\n function requireRole(minimumRole: RoleType, message: string) {\n if (!ROLE_HIERARCHY.includes(minimumRole)) {\n throw new Error(`Unknown role for isolation check: ${minimumRole}`);\n }\n return t.middleware(({ ctx, next }) => {\n if (!ctx.user) {\n throw new TRPCError({\n code: \"UNAUTHORIZED\",\n message: UNAUTHED_ERR_MSG,\n });\n }\n if (!meetsMinimumRole(ctx.user.role, minimumRole)) {\n throw new TRPCError({ code: \"FORBIDDEN\", message });\n }\n return next();\n });\n }\n\n /** The one admin gate (ADMIN_MIN_ROLE). */\n const requireAdmin = requireRole(ADMIN_MIN_ROLE, NOT_ADMIN_ERR_MSG);\n\n // ops#246: the live check's login can look but not change anything. Every\n // procedure below starts here, so no mutation from a read-only account\n // reaches a resolver, whatever its role says. Runs before any input is read.\n const refuseReadOnlyWrites = t.middleware(({ ctx, type, next }) => {\n if (type === \"mutation\" && ctx.user?.readOnly === true) {\n throw new TRPCError({ code: \"FORBIDDEN\", message: READ_ONLY_ERR_MSG });\n }\n return next();\n });\n const baseProcedure = t.procedure.use(refuseReadOnlyWrites);\n\n const protectedProcedure = baseProcedure.use(requireUser);\n const enterpriseProcedure = baseProcedure.use(requireEnterprise);\n\n return {\n /**\n * For public procedures and any app tier of its own: no sign-in needed, but\n * a signed-in read-only account's writes are still refused.\n */\n readOnlyGuardedProcedure: baseProcedure,\n protectedProcedure,\n enterpriseProcedure,\n adminProcedure: protectedProcedure.use(requireAdmin),\n enterpriseAdminProcedure: enterpriseProcedure.use(requireAdmin),\n platformAdminProcedure: protectedProcedure.use(\n requireRole(\"platform_admin\", NOT_PLATFORM_ADMIN_ERR_MSG)\n ),\n };\n}\n\nexport type IsolationProcedures<TUser extends IsolationUser = IsolationUser> =\n ReturnType<typeof createIsolationProcedures<TUser>>;\n\n/**\n * Express guard for write routes that are not tRPC (ops#246): a read-only\n * account gets 403 on every POST, PUT, PATCH and DELETE. `getUser` is the\n * app's own sign-in lookup (for example `req => auth.authenticateRequest(req)`);\n * an unauthenticated caller (it throws or returns null) is left to the route.\n * Mount it before the app's write routes.\n */\nexport function refuseReadOnlyWritesExpress(\n getUser: (req: Request) => Promise<IsolationUser | null | undefined>\n) {\n const WRITE = new Set([\"POST\", \"PUT\", \"PATCH\", \"DELETE\"]);\n return async (req: Request, res: Response, next: NextFunction) => {\n if (!WRITE.has(String(req.method).toUpperCase())) return next();\n let user: IsolationUser | null | undefined;\n try {\n user = await getUser(req);\n } catch {\n return next();\n }\n if (user?.readOnly === true) {\n return res.status(403).json({ error: READ_ONLY_ERR_MSG });\n }\n return next();\n };\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../src/isolation.ts"],"sourcesContent":["/**\n * Enterprise Isolation — the one enforced tenant-scoping path for every service.\n *\n * Replaces the per-app copies of `requireEnterprise` / `enterpriseProcedure` and\n * the hand-written `rows.filter(r => r.enterpriseId === id)` and \"does this\n * record belong to the caller?\" checks. Roles and error messages come from\n * `@pablo2410/shared-ui/rbac`, the platform's single role model.\n *\n * Every check fails closed: no user → UNAUTHORIZED; no (valid) enterprise →\n * FORBIDDEN / no rows; an unknown role never passes a role check; a record with\n * no enterpriseId never matches, even against a caller with none.\n *\n * Quick example (each app keeps its own `t`; the procedures are concatenated\n * onto it, so the app's transformer, context and error formatter still apply):\n *\n * import { createIsolationProcedures } from \"@pablo2410/core-server/isolation\";\n * const iso = createIsolationProcedures<SessionUser>();\n * export const enterpriseProcedure = t.procedure.concat(iso.enterpriseProcedure);\n * // in a resolver: ctx.enterpriseId is a number, ctx.user is non-null\n * listPlans: enterpriseProcedure.query(async ({ ctx }) =>\n * scopeToEnterprise(await getPlans(), ctx.enterpriseId)),\n */\nimport type { NextFunction, Request, Response } from \"express\";\nimport { initTRPC, TRPCError } from \"@trpc/server\";\nimport {\n NO_ENTERPRISE_ERR_MSG,\n NOT_ADMIN_ERR_MSG,\n NOT_PLATFORM_ADMIN_ERR_MSG,\n ROLE_HIERARCHY,\n UNAUTHED_ERR_MSG,\n type RoleType,\n} from \"@pablo2410/shared-ui/rbac\";\n\n/** The user fields isolation needs. Each app's session user must have these. */\nexport interface IsolationUser {\n role: string;\n enterpriseId?: number | null;\n /** True for a read-only account (ops#246): every write from it is refused. */\n readOnly?: boolean;\n}\n\n/** What a read-only account is told when it tries to change something. */\nexport const READ_ONLY_ERR_MSG =\n \"This account is read-only: it can look but not change anything.\";\n\n/**\n * The only mutation a read-only account may call (core-server#129): signing\n * itself out, `auth.logout` in every app. It ends the caller's own session and\n * changes nothing else, so the live check's login can sign out after each run\n * instead of leaving its session open. Matched on the full procedure path, so\n * any other mutation, even one named \"logout\" elsewhere (`admin.logout`,\n * `auth.logoutAll`), is still refused.\n */\nexport const READ_ONLY_ALLOWED_MUTATIONS: readonly string[] = Object.freeze([\n \"auth.logout\",\n]);\n\n/** The context fields isolation needs. Each app's tRPC context must have these. */\nexport interface IsolationContext<TUser extends IsolationUser = IsolationUser> {\n user: TUser | null;\n}\n\n/** Anything owned by one enterprise (a row from the DB or the Portal API). */\nexport interface EnterpriseOwned {\n enterpriseId?: number | null;\n}\n\n/** Only positive integers are real enterprise IDs. */\nfunction isEnterpriseId(value: unknown): value is number {\n return typeof value === \"number\" && Number.isInteger(value) && value > 0;\n}\n\n/**\n * True when `userRole` is a known role at or above `minimumRole`.\n * Unlike a bare indexOf comparison, an unknown role on either side is never\n * treated as meeting the bar.\n */\nfunction meetsMinimumRole(\n userRole: string | null | undefined,\n minimumRole: RoleType\n): boolean {\n const user = ROLE_HIERARCHY.indexOf(userRole as RoleType);\n const min = ROLE_HIERARCHY.indexOf(minimumRole);\n return user >= 0 && min >= 0 && user >= min;\n}\n\n/**\n * The fleet's one admin rule: enterprise_admin and above (enterprise_admin,\n * platform_admin). Paul, 5 Oct 2026 (core-server#74). Every admin check in\n * every app uses this; no app sets its own admin threshold, and superuser is\n * not an admin level.\n */\nexport const ADMIN_MIN_ROLE = \"enterprise_admin\" as const satisfies RoleType;\n\n/** True when `role` passes the fleet admin rule (ADMIN_MIN_ROLE and above). */\nexport function isAdminRole(role: string | null | undefined): boolean {\n return meetsMinimumRole(role, ADMIN_MIN_ROLE);\n}\n\n/**\n * The caller's enterprise, or null. Never a default tenant: a missing,\n * zero, negative or non-integer enterpriseId is null.\n */\nexport function resolveEnterpriseId(\n user: IsolationUser | null | undefined\n): number | null {\n const id = user?.enterpriseId;\n return isEnterpriseId(id) ? id : null;\n}\n\n/** Like resolveEnterpriseId, but throws UNAUTHORIZED / FORBIDDEN instead of null. */\nexport function requireEnterpriseId(\n user: IsolationUser | null | undefined\n): number {\n if (!user) {\n throw new TRPCError({ code: \"UNAUTHORIZED\", message: UNAUTHED_ERR_MSG });\n }\n const id = resolveEnterpriseId(user);\n if (id === null) {\n throw new TRPCError({ code: \"FORBIDDEN\", message: NO_ENTERPRISE_ERR_MSG });\n }\n return id;\n}\n\n/**\n * Keep only the rows owned by `enterpriseId`. A null/invalid enterprise gets no\n * rows; a row with no enterpriseId is never included.\n */\nexport function scopeToEnterprise<T extends EnterpriseOwned>(\n rows: readonly T[],\n enterpriseId: number | null | undefined\n): T[] {\n if (!isEnterpriseId(enterpriseId)) return [];\n return rows.filter(row => row.enterpriseId === enterpriseId);\n}\n\nexport interface OwnershipMessages {\n /** Message when the record does not exist. Default \"Not found\". */\n notFound?: string;\n /** Message when the record belongs to another enterprise. */\n forbidden?: string;\n}\n\n/**\n * Return `record` if it belongs to `enterpriseId`; otherwise throw.\n * Missing record → NOT_FOUND. Other enterprise, a record with no enterpriseId,\n * or a caller with no enterprise → FORBIDDEN.\n */\nexport function assertEnterpriseOwnership<T extends EnterpriseOwned>(\n record: T | null | undefined,\n enterpriseId: number | null | undefined,\n messages: OwnershipMessages = {}\n): T {\n if (record === null || record === undefined) {\n throw new TRPCError({\n code: \"NOT_FOUND\",\n message: messages.notFound ?? \"Not found\",\n });\n }\n if (!isEnterpriseId(enterpriseId) || record.enterpriseId !== enterpriseId) {\n throw new TRPCError({\n code: \"FORBIDDEN\",\n message:\n messages.forbidden ??\n \"Access denied: this record belongs to another enterprise\",\n });\n }\n return record;\n}\n\n/**\n * Build the standard procedures for an app whose session user is `TUser`.\n * Attach each one to the app's own tRPC instance with `t.procedure.concat(...)`.\n *\n * | Procedure | Auth | Enterprise | Min role |\n * |--------------------------|:----:|:----------:|-----------------------------|\n * | protectedProcedure | Yes | No | any |\n * | enterpriseProcedure | Yes | Yes | any |\n * | adminProcedure | Yes | No | ADMIN_MIN_ROLE (admin gate) |\n * | enterpriseAdminProcedure | Yes | Yes | ADMIN_MIN_ROLE (admin gate) |\n * | platformAdminProcedure | Yes | No | platform_admin |\n *\n * Both admin procedures share one admin gate built from ADMIN_MIN_ROLE.\n * Enterprise procedures add `ctx.enterpriseId: number` and narrow `ctx.user`\n * to non-null. Checks run in order: user, then enterprise, then role.\n */\nexport function createIsolationProcedures<\n TUser extends IsolationUser = IsolationUser,\n>() {\n const t = initTRPC.context<IsolationContext<TUser>>().create();\n\n const requireUser = t.middleware(({ ctx, next }) => {\n if (!ctx.user) {\n throw new TRPCError({ code: \"UNAUTHORIZED\", message: UNAUTHED_ERR_MSG });\n }\n return next({ ctx: { user: ctx.user } });\n });\n\n const requireEnterprise = t.middleware(({ ctx, next }) => {\n const enterpriseId = requireEnterpriseId(ctx.user);\n return next({ ctx: { user: ctx.user as TUser, enterpriseId } });\n });\n\n function requireRole(minimumRole: RoleType, message: string) {\n if (!ROLE_HIERARCHY.includes(minimumRole)) {\n throw new Error(`Unknown role for isolation check: ${minimumRole}`);\n }\n return t.middleware(({ ctx, next }) => {\n if (!ctx.user) {\n throw new TRPCError({\n code: \"UNAUTHORIZED\",\n message: UNAUTHED_ERR_MSG,\n });\n }\n if (!meetsMinimumRole(ctx.user.role, minimumRole)) {\n throw new TRPCError({ code: \"FORBIDDEN\", message });\n }\n return next();\n });\n }\n\n /** The one admin gate (ADMIN_MIN_ROLE). */\n const requireAdmin = requireRole(ADMIN_MIN_ROLE, NOT_ADMIN_ERR_MSG);\n\n // ops#246: the live check's login can look but not change anything. Every\n // procedure below starts here, so no mutation from a read-only account\n // reaches a resolver, whatever its role says. Runs before any input is read.\n // The one exception is signing itself out (READ_ONLY_ALLOWED_MUTATIONS).\n const refuseReadOnlyWrites = t.middleware(({ ctx, type, path, next }) => {\n if (\n type === \"mutation\" &&\n ctx.user?.readOnly === true &&\n !READ_ONLY_ALLOWED_MUTATIONS.includes(path)\n ) {\n throw new TRPCError({ code: \"FORBIDDEN\", message: READ_ONLY_ERR_MSG });\n }\n return next();\n });\n const baseProcedure = t.procedure.use(refuseReadOnlyWrites);\n\n const protectedProcedure = baseProcedure.use(requireUser);\n const enterpriseProcedure = baseProcedure.use(requireEnterprise);\n\n return {\n /**\n * For public procedures and any app tier of its own: no sign-in needed, but\n * a signed-in read-only account's writes are still refused.\n */\n readOnlyGuardedProcedure: baseProcedure,\n protectedProcedure,\n enterpriseProcedure,\n adminProcedure: protectedProcedure.use(requireAdmin),\n enterpriseAdminProcedure: enterpriseProcedure.use(requireAdmin),\n platformAdminProcedure: protectedProcedure.use(\n requireRole(\"platform_admin\", NOT_PLATFORM_ADMIN_ERR_MSG)\n ),\n };\n}\n\nexport type IsolationProcedures<TUser extends IsolationUser = IsolationUser> =\n ReturnType<typeof createIsolationProcedures<TUser>>;\n\n/**\n * Express guard for write routes that are not tRPC (ops#246): a read-only\n * account gets 403 on every POST, PUT, PATCH and DELETE. `getUser` is the\n * app's own sign-in lookup (for example `req => auth.authenticateRequest(req)`);\n * an unauthenticated caller (it throws or returns null) is left to the route.\n * Mount it before the app's write routes.\n */\nexport function refuseReadOnlyWritesExpress(\n getUser: (req: Request) => Promise<IsolationUser | null | undefined>\n) {\n const WRITE = new Set([\"POST\", \"PUT\", \"PATCH\", \"DELETE\"]);\n return async (req: Request, res: Response, next: NextFunction) => {\n if (!WRITE.has(String(req.method).toUpperCase())) return next();\n let user: IsolationUser | null | undefined;\n try {\n user = await getUser(req);\n } catch {\n return next();\n }\n if (user?.readOnly === true) {\n return res.status(403).json({ error: READ_ONLY_ERR_MSG });\n }\n return next();\n };\n}\n"],"mappings":"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","names":[]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pablo2410/core-server",
3
- "version": "0.15.1",
3
+ "version": "0.15.2",
4
4
  "description": "Shared server utilities for Oplytics.digital subdomains",
5
5
  "type": "module",
6
6
  "exports": {