@porulle/core 0.7.0 → 0.9.0

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.
Files changed (325) hide show
  1. package/dist/auth/middleware.d.ts.map +1 -1
  2. package/dist/auth/middleware.js +26 -7
  3. package/dist/auth/setup.d.ts.map +1 -1
  4. package/dist/auth/setup.js +8 -5
  5. package/dist/config/defaults.d.ts.map +1 -1
  6. package/dist/config/defaults.js +7 -0
  7. package/dist/config/types.d.ts +26 -1
  8. package/dist/config/types.d.ts.map +1 -1
  9. package/dist/generated/plugin-manifest.d.ts +3 -0
  10. package/dist/generated/plugin-manifest.d.ts.map +1 -1
  11. package/dist/generated/plugin-manifest.js +1 -0
  12. package/dist/hooks/checkout-completion.d.ts.map +1 -1
  13. package/dist/hooks/checkout-completion.js +2 -2
  14. package/dist/hooks/checkout.d.ts.map +1 -1
  15. package/dist/hooks/checkout.js +17 -2
  16. package/dist/index.d.ts +1 -0
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/index.js +1 -0
  19. package/dist/interfaces/rest/index.d.ts.map +1 -1
  20. package/dist/interfaces/rest/index.js +12 -0
  21. package/dist/interfaces/rest/routes/admin/staff.d.ts +12 -0
  22. package/dist/interfaces/rest/routes/admin/staff.d.ts.map +1 -0
  23. package/dist/interfaces/rest/routes/admin/staff.js +206 -0
  24. package/dist/interfaces/rest/routes/analytics.d.ts +5 -0
  25. package/dist/interfaces/rest/routes/analytics.d.ts.map +1 -0
  26. package/dist/interfaces/rest/routes/analytics.js +24 -0
  27. package/dist/interfaces/rest/routes/carts.d.ts.map +1 -1
  28. package/dist/interfaces/rest/routes/carts.js +39 -2
  29. package/dist/interfaces/rest/routes/catalog.d.ts.map +1 -1
  30. package/dist/interfaces/rest/routes/catalog.js +17 -1
  31. package/dist/interfaces/rest/routes/checkout.d.ts +9 -0
  32. package/dist/interfaces/rest/routes/checkout.d.ts.map +1 -1
  33. package/dist/interfaces/rest/routes/checkout.js +97 -21
  34. package/dist/interfaces/rest/routes/documents.d.ts +10 -0
  35. package/dist/interfaces/rest/routes/documents.d.ts.map +1 -0
  36. package/dist/interfaces/rest/routes/documents.js +40 -0
  37. package/dist/interfaces/rest/routes/inventory.d.ts.map +1 -1
  38. package/dist/interfaces/rest/routes/inventory.js +13 -0
  39. package/dist/interfaces/rest/routes/orders.d.ts.map +1 -1
  40. package/dist/interfaces/rest/routes/orders.js +111 -1
  41. package/dist/interfaces/rest/routes/pricing.d.ts.map +1 -1
  42. package/dist/interfaces/rest/routes/pricing.js +36 -1
  43. package/dist/interfaces/rest/routes/settings.d.ts +5 -0
  44. package/dist/interfaces/rest/routes/settings.d.ts.map +1 -0
  45. package/dist/interfaces/rest/routes/settings.js +50 -0
  46. package/dist/interfaces/rest/routes/shipping.d.ts +5 -0
  47. package/dist/interfaces/rest/routes/shipping.d.ts.map +1 -0
  48. package/dist/interfaces/rest/routes/shipping.js +68 -0
  49. package/dist/interfaces/rest/routes/tax.d.ts +5 -0
  50. package/dist/interfaces/rest/routes/tax.d.ts.map +1 -0
  51. package/dist/interfaces/rest/routes/tax.js +68 -0
  52. package/dist/interfaces/rest/schemas/admin-staff.d.ts +899 -0
  53. package/dist/interfaces/rest/schemas/admin-staff.d.ts.map +1 -0
  54. package/dist/interfaces/rest/schemas/admin-staff.js +98 -0
  55. package/dist/interfaces/rest/schemas/analytics.d.ts +252 -0
  56. package/dist/interfaces/rest/schemas/analytics.d.ts.map +1 -0
  57. package/dist/interfaces/rest/schemas/analytics.js +33 -0
  58. package/dist/interfaces/rest/schemas/carts.d.ts +656 -0
  59. package/dist/interfaces/rest/schemas/carts.d.ts.map +1 -1
  60. package/dist/interfaces/rest/schemas/carts.js +38 -0
  61. package/dist/interfaces/rest/schemas/catalog.d.ts +1218 -1
  62. package/dist/interfaces/rest/schemas/catalog.d.ts.map +1 -1
  63. package/dist/interfaces/rest/schemas/catalog.js +48 -0
  64. package/dist/interfaces/rest/schemas/checkout.d.ts +2 -0
  65. package/dist/interfaces/rest/schemas/checkout.d.ts.map +1 -1
  66. package/dist/interfaces/rest/schemas/checkout.js +4 -0
  67. package/dist/interfaces/rest/schemas/customer-portal.d.ts +35 -0
  68. package/dist/interfaces/rest/schemas/customer-portal.d.ts.map +1 -1
  69. package/dist/interfaces/rest/schemas/documents.d.ts +509 -0
  70. package/dist/interfaces/rest/schemas/documents.d.ts.map +1 -0
  71. package/dist/interfaces/rest/schemas/documents.js +65 -0
  72. package/dist/interfaces/rest/schemas/orders.d.ts +3110 -43
  73. package/dist/interfaces/rest/schemas/orders.d.ts.map +1 -1
  74. package/dist/interfaces/rest/schemas/orders.js +234 -1
  75. package/dist/interfaces/rest/schemas/pricing.d.ts +405 -0
  76. package/dist/interfaces/rest/schemas/pricing.d.ts.map +1 -1
  77. package/dist/interfaces/rest/schemas/pricing.js +70 -0
  78. package/dist/interfaces/rest/schemas/responses.d.ts +190 -0
  79. package/dist/interfaces/rest/schemas/responses.d.ts.map +1 -1
  80. package/dist/interfaces/rest/schemas/search.d.ts +17 -0
  81. package/dist/interfaces/rest/schemas/search.d.ts.map +1 -1
  82. package/dist/interfaces/rest/schemas/settings.d.ts +397 -0
  83. package/dist/interfaces/rest/schemas/settings.d.ts.map +1 -0
  84. package/dist/interfaces/rest/schemas/settings.js +79 -0
  85. package/dist/interfaces/rest/schemas/shipping.d.ts +1093 -0
  86. package/dist/interfaces/rest/schemas/shipping.d.ts.map +1 -0
  87. package/dist/interfaces/rest/schemas/shipping.js +146 -0
  88. package/dist/interfaces/rest/schemas/tax.d.ts +1074 -0
  89. package/dist/interfaces/rest/schemas/tax.d.ts.map +1 -0
  90. package/dist/interfaces/rest/schemas/tax.js +132 -0
  91. package/dist/kernel/database/schema.d.ts +4 -0
  92. package/dist/kernel/database/schema.d.ts.map +1 -1
  93. package/dist/kernel/database/schema.js +8 -0
  94. package/dist/kernel/database/scoped-db.d.ts +2 -1
  95. package/dist/kernel/database/scoped-db.d.ts.map +1 -1
  96. package/dist/kernel/database/scoped-db.js +62 -1
  97. package/dist/kernel/hooks/types.d.ts +1 -1
  98. package/dist/kernel/hooks/types.d.ts.map +1 -1
  99. package/dist/kernel/plugin/manifest.d.ts +26 -0
  100. package/dist/kernel/plugin/manifest.d.ts.map +1 -1
  101. package/dist/kernel/plugin/manifest.js +17 -2
  102. package/dist/modules/analytics/drizzle-adapter.d.ts.map +1 -1
  103. package/dist/modules/analytics/drizzle-adapter.js +16 -3
  104. package/dist/modules/analytics/module.d.ts.map +1 -1
  105. package/dist/modules/analytics/module.js +6 -0
  106. package/dist/modules/analytics/reports.d.ts +58 -0
  107. package/dist/modules/analytics/reports.d.ts.map +1 -0
  108. package/dist/modules/analytics/reports.js +249 -0
  109. package/dist/modules/analytics/service.d.ts +10 -0
  110. package/dist/modules/analytics/service.d.ts.map +1 -1
  111. package/dist/modules/analytics/service.js +14 -0
  112. package/dist/modules/cart/repository/index.d.ts +7 -0
  113. package/dist/modules/cart/repository/index.d.ts.map +1 -1
  114. package/dist/modules/cart/repository/index.js +25 -1
  115. package/dist/modules/cart/schema.d.ts +17 -0
  116. package/dist/modules/cart/schema.d.ts.map +1 -1
  117. package/dist/modules/cart/schema.js +2 -0
  118. package/dist/modules/cart/schemas.d.ts +1 -0
  119. package/dist/modules/cart/schemas.d.ts.map +1 -1
  120. package/dist/modules/cart/schemas.js +1 -0
  121. package/dist/modules/cart/service.d.ts +32 -0
  122. package/dist/modules/cart/service.d.ts.map +1 -1
  123. package/dist/modules/cart/service.js +61 -2
  124. package/dist/modules/catalog/entity-service.d.ts +21 -0
  125. package/dist/modules/catalog/entity-service.d.ts.map +1 -1
  126. package/dist/modules/catalog/entity-service.js +166 -5
  127. package/dist/modules/catalog/schema.d.ts +34 -0
  128. package/dist/modules/catalog/schema.d.ts.map +1 -1
  129. package/dist/modules/catalog/schema.js +5 -0
  130. package/dist/modules/catalog/schemas.d.ts +3 -0
  131. package/dist/modules/catalog/schemas.d.ts.map +1 -1
  132. package/dist/modules/catalog/schemas.js +3 -0
  133. package/dist/modules/catalog/service.d.ts +36 -0
  134. package/dist/modules/catalog/service.d.ts.map +1 -1
  135. package/dist/modules/catalog/service.js +6 -0
  136. package/dist/modules/customers/service.d.ts.map +1 -1
  137. package/dist/modules/customers/service.js +6 -0
  138. package/dist/modules/documents/module.d.ts +7 -0
  139. package/dist/modules/documents/module.d.ts.map +1 -0
  140. package/dist/modules/documents/module.js +12 -0
  141. package/dist/modules/documents/pdf.d.ts +25 -0
  142. package/dist/modules/documents/pdf.d.ts.map +1 -0
  143. package/dist/modules/documents/pdf.js +80 -0
  144. package/dist/modules/documents/render.d.ts +32 -0
  145. package/dist/modules/documents/render.d.ts.map +1 -0
  146. package/dist/modules/documents/render.js +190 -0
  147. package/dist/modules/documents/repository/index.d.ts +25 -0
  148. package/dist/modules/documents/repository/index.d.ts.map +1 -0
  149. package/dist/modules/documents/repository/index.js +47 -0
  150. package/dist/modules/documents/schema.d.ts +214 -0
  151. package/dist/modules/documents/schema.d.ts.map +1 -0
  152. package/dist/modules/documents/schema.js +42 -0
  153. package/dist/modules/documents/service.d.ts +51 -0
  154. package/dist/modules/documents/service.d.ts.map +1 -0
  155. package/dist/modules/documents/service.js +119 -0
  156. package/dist/modules/fulfillment/service.d.ts +18 -0
  157. package/dist/modules/fulfillment/service.d.ts.map +1 -1
  158. package/dist/modules/fulfillment/service.js +85 -1
  159. package/dist/modules/orders/repository/index.d.ts +18 -1
  160. package/dist/modules/orders/repository/index.d.ts.map +1 -1
  161. package/dist/modules/orders/repository/index.js +86 -1
  162. package/dist/modules/orders/schema.d.ts +391 -0
  163. package/dist/modules/orders/schema.d.ts.map +1 -1
  164. package/dist/modules/orders/schema.js +56 -1
  165. package/dist/modules/orders/service.d.ts +102 -3
  166. package/dist/modules/orders/service.d.ts.map +1 -1
  167. package/dist/modules/orders/service.js +653 -29
  168. package/dist/modules/orders/stale-order-cleanup.d.ts.map +1 -1
  169. package/dist/modules/orders/stale-order-cleanup.js +13 -1
  170. package/dist/modules/pricing/repository/index.d.ts +5 -0
  171. package/dist/modules/pricing/repository/index.d.ts.map +1 -1
  172. package/dist/modules/pricing/repository/index.js +19 -0
  173. package/dist/modules/pricing/service.d.ts +18 -0
  174. package/dist/modules/pricing/service.d.ts.map +1 -1
  175. package/dist/modules/pricing/service.js +43 -5
  176. package/dist/modules/settings/module.d.ts +6 -0
  177. package/dist/modules/settings/module.d.ts.map +1 -0
  178. package/dist/modules/settings/module.js +11 -0
  179. package/dist/modules/settings/repository/index.d.ts +16 -0
  180. package/dist/modules/settings/repository/index.d.ts.map +1 -0
  181. package/dist/modules/settings/repository/index.js +41 -0
  182. package/dist/modules/settings/schema.d.ts +121 -0
  183. package/dist/modules/settings/schema.d.ts.map +1 -0
  184. package/dist/modules/settings/schema.js +24 -0
  185. package/dist/modules/settings/service.d.ts +26 -0
  186. package/dist/modules/settings/service.d.ts.map +1 -0
  187. package/dist/modules/settings/service.js +51 -0
  188. package/dist/modules/shipping/calculator.d.ts +5 -0
  189. package/dist/modules/shipping/calculator.d.ts.map +1 -1
  190. package/dist/modules/shipping/calculator.js +10 -0
  191. package/dist/modules/shipping/module.d.ts +5 -1
  192. package/dist/modules/shipping/module.d.ts.map +1 -1
  193. package/dist/modules/shipping/module.js +4 -1
  194. package/dist/modules/shipping/repository/index.d.ts +27 -2
  195. package/dist/modules/shipping/repository/index.d.ts.map +1 -1
  196. package/dist/modules/shipping/repository/index.js +105 -1
  197. package/dist/modules/shipping/schema.d.ts +419 -0
  198. package/dist/modules/shipping/schema.d.ts.map +1 -0
  199. package/dist/modules/shipping/schema.js +56 -0
  200. package/dist/modules/shipping/service.d.ts +54 -0
  201. package/dist/modules/shipping/service.d.ts.map +1 -1
  202. package/dist/modules/shipping/service.js +161 -2
  203. package/dist/modules/tax/adapter.d.ts +13 -0
  204. package/dist/modules/tax/adapter.d.ts.map +1 -1
  205. package/dist/modules/tax/module.d.ts +5 -1
  206. package/dist/modules/tax/module.d.ts.map +1 -1
  207. package/dist/modules/tax/module.js +4 -1
  208. package/dist/modules/tax/repository/index.d.ts +38 -2
  209. package/dist/modules/tax/repository/index.d.ts.map +1 -1
  210. package/dist/modules/tax/repository/index.js +138 -1
  211. package/dist/modules/tax/schema.d.ts +355 -0
  212. package/dist/modules/tax/schema.d.ts.map +1 -0
  213. package/dist/modules/tax/schema.js +55 -0
  214. package/dist/modules/tax/service.d.ts +60 -1
  215. package/dist/modules/tax/service.d.ts.map +1 -1
  216. package/dist/modules/tax/service.js +227 -2
  217. package/dist/runtime/kernel-modules.d.ts +15 -2
  218. package/dist/runtime/kernel-modules.d.ts.map +1 -1
  219. package/dist/runtime/kernel-modules.js +4 -0
  220. package/dist/runtime/kernel-types.d.ts +5 -1
  221. package/dist/runtime/kernel-types.d.ts.map +1 -1
  222. package/dist/runtime/kernel-types.js +2 -0
  223. package/dist/runtime/server.js +1 -1
  224. package/dist/test-utils/create-plugin-test-app.d.ts +3 -0
  225. package/dist/test-utils/create-plugin-test-app.d.ts.map +1 -1
  226. package/dist/test-utils/create-plugin-test-app.js +15 -3
  227. package/dist/test-utils/create-test-config.d.ts.map +1 -1
  228. package/dist/test-utils/create-test-config.js +2 -0
  229. package/dist/test-utils/order-test-helpers.d.ts +12 -0
  230. package/dist/test-utils/order-test-helpers.d.ts.map +1 -0
  231. package/dist/test-utils/order-test-helpers.js +18 -0
  232. package/dist/test-utils/rest-api-test-utils.d.ts.map +1 -1
  233. package/dist/test-utils/rest-api-test-utils.js +7 -0
  234. package/dist/test-utils/test-actors.js +1 -1
  235. package/dist/testing.d.ts +1 -0
  236. package/dist/testing.d.ts.map +1 -1
  237. package/dist/testing.js +1 -0
  238. package/package.json +3 -3
  239. package/src/auth/middleware.ts +26 -7
  240. package/src/auth/setup.ts +11 -4
  241. package/src/config/defaults.ts +7 -0
  242. package/src/config/types.ts +26 -1
  243. package/src/generated/plugin-capabilities.d.ts +1 -0
  244. package/src/generated/plugin-manifest.ts +1 -0
  245. package/src/generated/plugin-repositories.d.ts +1 -0
  246. package/src/hooks/checkout-completion.ts +22 -16
  247. package/src/hooks/checkout.ts +36 -11
  248. package/src/index.ts +1 -0
  249. package/src/interfaces/rest/index.ts +12 -0
  250. package/src/interfaces/rest/routes/admin/staff.ts +260 -0
  251. package/src/interfaces/rest/routes/analytics.ts +33 -0
  252. package/src/interfaces/rest/routes/carts.ts +52 -2
  253. package/src/interfaces/rest/routes/catalog.ts +34 -0
  254. package/src/interfaces/rest/routes/checkout.ts +114 -27
  255. package/src/interfaces/rest/routes/documents.ts +61 -0
  256. package/src/interfaces/rest/routes/inventory.ts +14 -0
  257. package/src/interfaces/rest/routes/orders.ts +148 -1
  258. package/src/interfaces/rest/routes/pricing.ts +44 -1
  259. package/src/interfaces/rest/routes/settings.ts +61 -0
  260. package/src/interfaces/rest/routes/shipping.ts +92 -0
  261. package/src/interfaces/rest/routes/tax.ts +90 -0
  262. package/src/interfaces/rest/schemas/admin-staff.ts +110 -0
  263. package/src/interfaces/rest/schemas/analytics.ts +39 -0
  264. package/src/interfaces/rest/schemas/carts.ts +40 -0
  265. package/src/interfaces/rest/schemas/catalog.ts +53 -0
  266. package/src/interfaces/rest/schemas/checkout.ts +4 -0
  267. package/src/interfaces/rest/schemas/documents.ts +72 -0
  268. package/src/interfaces/rest/schemas/orders.ts +258 -1
  269. package/src/interfaces/rest/schemas/pricing.ts +75 -0
  270. package/src/interfaces/rest/schemas/settings.ts +93 -0
  271. package/src/interfaces/rest/schemas/shipping.ts +164 -0
  272. package/src/interfaces/rest/schemas/tax.ts +148 -0
  273. package/src/kernel/database/schema.ts +12 -0
  274. package/src/kernel/database/scoped-db.ts +70 -1
  275. package/src/kernel/hooks/types.ts +1 -0
  276. package/src/kernel/plugin/manifest.ts +41 -2
  277. package/src/modules/analytics/drizzle-adapter.ts +19 -3
  278. package/src/modules/analytics/module.ts +10 -0
  279. package/src/modules/analytics/reports.ts +311 -0
  280. package/src/modules/analytics/service.ts +23 -0
  281. package/src/modules/cart/repository/index.ts +30 -1
  282. package/src/modules/cart/schema.ts +2 -0
  283. package/src/modules/cart/schemas.ts +1 -0
  284. package/src/modules/cart/service.ts +104 -2
  285. package/src/modules/catalog/entity-service.ts +196 -4
  286. package/src/modules/catalog/schema.ts +5 -0
  287. package/src/modules/catalog/schemas.ts +3 -0
  288. package/src/modules/catalog/service.ts +30 -0
  289. package/src/modules/customers/service.ts +6 -0
  290. package/src/modules/documents/module.ts +19 -0
  291. package/src/modules/documents/pdf.ts +101 -0
  292. package/src/modules/documents/render.ts +231 -0
  293. package/src/modules/documents/repository/index.ts +68 -0
  294. package/src/modules/documents/schema.ts +44 -0
  295. package/src/modules/documents/service.ts +176 -0
  296. package/src/modules/fulfillment/service.ts +139 -1
  297. package/src/modules/orders/repository/index.ts +127 -1
  298. package/src/modules/orders/schema.ts +58 -1
  299. package/src/modules/orders/service.ts +1041 -65
  300. package/src/modules/orders/stale-order-cleanup.ts +18 -4
  301. package/src/modules/pricing/repository/index.ts +34 -0
  302. package/src/modules/pricing/service.ts +76 -6
  303. package/src/modules/settings/module.ts +18 -0
  304. package/src/modules/settings/repository/index.ts +59 -0
  305. package/src/modules/settings/schema.ts +25 -0
  306. package/src/modules/settings/service.ts +82 -0
  307. package/src/modules/shipping/calculator.ts +27 -0
  308. package/src/modules/shipping/module.ts +6 -2
  309. package/src/modules/shipping/repository/index.ts +136 -2
  310. package/src/modules/shipping/schema.ts +58 -0
  311. package/src/modules/shipping/service.ts +260 -1
  312. package/src/modules/tax/adapter.ts +9 -0
  313. package/src/modules/tax/module.ts +6 -2
  314. package/src/modules/tax/repository/index.ts +169 -2
  315. package/src/modules/tax/schema.ts +57 -0
  316. package/src/modules/tax/service.ts +313 -2
  317. package/src/runtime/kernel-modules.ts +4 -0
  318. package/src/runtime/kernel-types.ts +6 -0
  319. package/src/runtime/server.ts +1 -1
  320. package/src/test-utils/create-plugin-test-app.ts +17 -4
  321. package/src/test-utils/create-test-config.ts +2 -0
  322. package/src/test-utils/order-test-helpers.ts +26 -0
  323. package/src/test-utils/rest-api-test-utils.ts +7 -0
  324. package/src/test-utils/test-actors.ts +1 -1
  325. package/src/testing.ts +1 -0
@@ -43,10 +43,10 @@ export function authMiddleware(
43
43
  // Resolve the default org from config, falling back to deprecated constant
44
44
  const defaultOrgId = config.auth?.defaultOrganizationId ?? DEFAULT_ORG_ID;
45
45
 
46
- // Test-only actor injection: NODE_ENV === "test" honors x-test-actor for
47
- // route-level integration tests that bypass the Better Auth session
48
- // round-trip. Production/non-test environments ignore this header entirely.
49
- if (process.env.NODE_ENV === "test") {
46
+ // Test-only actor injection: requires NODE_ENV === "test" AND an explicit
47
+ // config opt-in so staging/preview deployments left as NODE_ENV=test do not
48
+ // silently become a full auth bypass.
49
+ if (process.env.NODE_ENV === "test" && config.auth?.allowTestActor) {
50
50
  const testActorHeader = c.req.header("x-test-actor");
51
51
  if (testActorHeader) {
52
52
  try {
@@ -172,10 +172,29 @@ export function authMiddleware(
172
172
  if (result?.valid && result.key) {
173
173
  const apiKey = result.key as Record<string, unknown>;
174
174
 
175
- // v1.5+ renamed userId → referenceId on the apikey table.
176
- const userId = (apiKey.referenceId ?? "") as string;
177
175
  const name = (apiKey.name ?? "API Key") as string;
178
- const orgId = (apiKey.organizationId ?? defaultOrgId) as string;
176
+ // Read the org (and operator identity) from key metadata when present.
177
+ // POS shift keys carry { organizationId, operatorId } in metadata so the
178
+ // operator is scoped to their store WITHOUT any org membership/role
179
+ // (SEC-16 / R-01). Other keys fall back to the key's organizationId.
180
+ const rawMeta = apiKey.metadata;
181
+ const meta =
182
+ rawMeta && typeof rawMeta === "object"
183
+ ? (rawMeta as Record<string, unknown>)
184
+ : typeof rawMeta === "string"
185
+ ? (() => {
186
+ try {
187
+ return JSON.parse(rawMeta) as Record<string, unknown>;
188
+ } catch {
189
+ return null;
190
+ }
191
+ })()
192
+ : null;
193
+ const metaOrg = typeof meta?.organizationId === "string" ? meta.organizationId : undefined;
194
+ const orgId = (metaOrg ?? apiKey.organizationId ?? defaultOrgId) as string;
195
+ const userId = ((typeof meta?.operatorId === "string" ? meta.operatorId : undefined)
196
+ ?? apiKey.referenceId
197
+ ?? "") as string;
179
198
 
180
199
  // Better Auth stores permissions as Record<string, string[]>
181
200
  // (e.g. {"catalog":["read","create"]}). Flatten to the
package/src/auth/setup.ts CHANGED
@@ -7,13 +7,13 @@ import type { CommerceConfig } from "../config/types.js";
7
7
  import type { DatabaseAdapter } from "../kernel/database/adapter.js";
8
8
  import * as authSchema from "./auth-schema.js";
9
9
 
10
- type BetterAuthDbProvider = "pg" | "mysql" | "sqlite";
10
+ type BetterAuthDbProvider = "pg";
11
11
 
12
12
  function resolveAuthDbProvider(provider: string): BetterAuthDbProvider {
13
13
  if (provider === "postgres" || provider === "postgresql" || provider === "pg") return "pg";
14
- if (provider === "mysql") return "mysql";
15
- if (provider === "sqlite") return "sqlite";
16
- throw new Error(`Unsupported auth database provider "${provider}".`);
14
+ throw new Error(
15
+ `Unsupported auth database provider "${provider}". Only PostgreSQL is supported.`,
16
+ );
17
17
  }
18
18
 
19
19
  interface AuthEmailPayload {
@@ -90,6 +90,13 @@ export function createAuth(
90
90
  },
91
91
  }
92
92
  : {}),
93
+ ...(scope.keyExpiration ? { keyExpiration: scope.keyExpiration } : {}),
94
+ ...("references" in scope && scope.references
95
+ ? { references: scope.references as "user" | "organization" }
96
+ : {}),
97
+ ...(scope.enableMetadata || scope.references === "organization"
98
+ ? { enableMetadata: true }
99
+ : {}),
93
100
  }));
94
101
  plugins.push(apiKey(apiKeyConfigs));
95
102
  } else if (config.auth?.apiKeys?.enabled) {
@@ -27,9 +27,16 @@ export const defaultConfig: Partial<CommerceConfig> = {
27
27
  "inventory:read",
28
28
  "inventory:adjust",
29
29
  "orders:read",
30
+ "orders:create",
30
31
  "orders:update",
32
+ "orders:manage",
31
33
  "cart:create",
32
34
  "cart:update",
35
+ "cart:manage",
36
+ "shipping:manage",
37
+ "tax:manage",
38
+ "settings:manage",
39
+ "analytics:read",
33
40
  "customers:read:self",
34
41
  "customers:update:self",
35
42
  ],
@@ -4,6 +4,7 @@ import type { BeforeHook, AfterHook } from "../kernel/hooks/types.js";
4
4
  import type { PaymentAdapter } from "../modules/payments/adapter.js";
5
5
  import type { StorageAdapter } from "../modules/media/adapter.js";
6
6
  import type { Kernel } from "../runtime/kernel.js";
7
+ import type { AuthInstance } from "../auth/setup.js";
7
8
  import type { DatabaseAdapter } from "../kernel/database/adapter.js";
8
9
  import type { TaxAdapter } from "../modules/tax/adapter.js";
9
10
  import type { SearchAdapter } from "../modules/search/adapter.js";
@@ -102,6 +103,20 @@ export interface ApiKeyScopeDefinition {
102
103
  /** Time window in milliseconds. */
103
104
  timeWindow: number;
104
105
  };
106
+ /**
107
+ * Expiry bounds for keys minted under this scope, in DAYS (Better Auth
108
+ * semantics; fractions allowed — 1/24 = one hour). Better Auth defaults
109
+ * minExpiresIn to 1 day, which blocks short-lived credentials like
110
+ * per-shift POS keys (issue #51).
111
+ */
112
+ keyExpiration?: {
113
+ minExpiresIn?: number;
114
+ maxExpiresIn?: number;
115
+ };
116
+ /** Better Auth ownership model — POS shift keys bind to an organization. */
117
+ references?: "user" | "organization";
118
+ /** Allow metadata on keys minted under this scope (e.g. operatorId on POS shift keys). */
119
+ enableMetadata?: boolean;
105
120
  }
106
121
 
107
122
  export interface AuthConfig {
@@ -203,6 +218,11 @@ export interface AuthConfig {
203
218
  * Appended to the core plugin list (organization, bearer, jwt, etc.).
204
219
  */
205
220
  extraAuthPlugins?: unknown[];
221
+ /**
222
+ * When true (and NODE_ENV === "test"), the `x-test-actor` header may inject
223
+ * a trusted actor for integration tests. Default false.
224
+ */
225
+ allowTestActor?: boolean;
206
226
  }
207
227
 
208
228
  export interface CartConfig {
@@ -374,7 +394,12 @@ export interface CommerceConfig {
374
394
  hooks?: Record<string, Array<(...args: unknown[]) => unknown>>;
375
395
  plugins?: CommercePlugin[];
376
396
  middleware?: MiddlewareHandler[];
377
- routes?: (app: Hono<any>, kernel: Kernel) => void;
397
+ /**
398
+ * Custom route registration. Receives the Better Auth instance as the
399
+ * third argument so integrations can mint/revoke API keys or call
400
+ * `auth.api.*` without module-global holder shims.
401
+ */
402
+ routes?: (app: Hono<any>, kernel: Kernel, auth?: AuthInstance) => void;
378
403
  /** Log level for structured logging. Default: "info". */
379
404
  logLevel?: "fatal" | "error" | "warn" | "info" | "debug" | "trace";
380
405
  /**
@@ -4,6 +4,7 @@
4
4
  export interface PluginCapabilityRegistryShape {
5
5
  "appointments": Record<string, unknown>;
6
6
  "gift-cards": Record<string, unknown>;
7
+ "layaway": Record<string, unknown>;
7
8
  "loyalty": Record<string, unknown>;
8
9
  "marketplace": Record<string, unknown>;
9
10
  "notifications": Record<string, unknown>;
@@ -5,6 +5,7 @@
5
5
  export const resolvedPluginManifest = [
6
6
  { id: "appointments", directory: "plugin-appointments" },
7
7
  { id: "gift-cards", directory: "plugin-gift-cards" },
8
+ { id: "layaway", directory: "plugin-layaway" },
8
9
  { id: "loyalty", directory: "plugin-loyalty" },
9
10
  { id: "marketplace", directory: "plugin-marketplace" },
10
11
  { id: "notifications", directory: "plugin-notifications" },
@@ -4,6 +4,7 @@
4
4
  export interface PluginRepositoryRegistryShape {
5
5
  "appointments": Record<string, unknown>;
6
6
  "gift-cards": Record<string, unknown>;
7
+ "layaway": Record<string, unknown>;
7
8
  "loyalty": Record<string, unknown>;
8
9
  "marketplace": Record<string, unknown>;
9
10
  "notifications": Record<string, unknown>;
@@ -13,14 +13,14 @@ interface InventoryServiceLike {
13
13
  quantity: number;
14
14
  orderId: string;
15
15
  performedBy: string;
16
- }): Promise<{ ok: boolean; error?: { message: string } }>;
16
+ }, actor?: unknown): Promise<{ ok: boolean; error?: { message: string } }>;
17
17
  release(input: {
18
18
  entityId: string;
19
19
  variantId?: string;
20
20
  quantity: number;
21
21
  orderId: string;
22
22
  performedBy: string;
23
- }): Promise<unknown>;
23
+ }, actor?: unknown): Promise<unknown>;
24
24
  }
25
25
 
26
26
  interface PaymentsServiceLike {
@@ -80,13 +80,16 @@ export const reserveInventoryStep: Step<
80
80
  const performedBy = ctx.hook.actor?.userId ?? "system";
81
81
 
82
82
  for (const item of data.lineItems) {
83
- const result = await inventory.reserve({
84
- entityId: item.entityId,
85
- ...(item.variantId != null ? { variantId: item.variantId } : {}),
86
- quantity: item.quantity,
87
- orderId: data.checkoutId,
88
- performedBy,
89
- });
83
+ const result = await inventory.reserve(
84
+ {
85
+ entityId: item.entityId,
86
+ ...(item.variantId != null ? { variantId: item.variantId } : {}),
87
+ quantity: item.quantity,
88
+ orderId: data.checkoutId,
89
+ performedBy,
90
+ },
91
+ ctx.hook.actor,
92
+ );
90
93
 
91
94
  if (!result.ok) {
92
95
  return Err(
@@ -112,13 +115,16 @@ export const reserveInventoryStep: Step<
112
115
  const performedBy = ctx.hook.actor?.userId ?? "system";
113
116
 
114
117
  for (const r of reservations) {
115
- await inventory.release({
116
- entityId: r.entityId,
117
- ...(r.variantId != null ? { variantId: r.variantId } : {}),
118
- quantity: r.quantity,
119
- orderId: r.orderId,
120
- performedBy,
121
- });
118
+ await inventory.release(
119
+ {
120
+ entityId: r.entityId,
121
+ ...(r.variantId != null ? { variantId: r.variantId } : {}),
122
+ quantity: r.quantity,
123
+ orderId: r.orderId,
124
+ performedBy,
125
+ },
126
+ ctx.hook.actor,
127
+ );
122
128
  }
123
129
  },
124
130
  };
@@ -359,28 +359,40 @@ export const calculateTax: BeforeHook<CheckoutData> = async ({
359
359
  currency: string;
360
360
  customerId?: string;
361
361
  shippingAmount: number;
362
+ orderDiscount?: number;
362
363
  fromAddress?: ShippingAddress;
363
364
  toAddress?: ShippingAddress;
364
365
  lineItems: Array<{
365
366
  id: string;
366
367
  entityId: string;
368
+ variantId?: string;
367
369
  description: string;
368
370
  quantity: number;
369
371
  unitPrice: number;
370
372
  discount?: number;
371
373
  }>;
372
- }): Promise<
373
- | { ok: true; value: { amountToCollect: number } }
374
+ }, orgId?: string): Promise<
375
+ | { ok: true; value: { amountToCollect: number; lines?: Array<{ id: string; taxClass: string | null; taxAmount: number }> } }
374
376
  | { ok: false; error: Error }
375
377
  >;
376
378
  };
377
379
 
380
+ // Cart-level discount not already attributed to lines — pro-rated across
381
+ // lines before class-based tax (issue #57).
382
+ const lineDiscounts = data.lineItems.reduce(
383
+ (sum, lineItem) => sum + (lineItem.discountAmount ?? 0),
384
+ 0,
385
+ );
386
+ const orderDiscount = Math.max(0, data.discountTotal - lineDiscounts);
387
+
378
388
  const calculated = await tax.calculate({
379
389
  currency: data.currency,
380
390
  shippingAmount: data.shippingTotal,
391
+ orderDiscount,
381
392
  lineItems: data.lineItems.map((lineItem) => ({
382
393
  id: lineItem.id ?? `${lineItem.entityId}:${lineItem.variantId ?? "_"}`,
383
394
  entityId: lineItem.entityId,
395
+ ...(lineItem.variantId !== undefined ? { variantId: lineItem.variantId } : {}),
384
396
  description: lineItem.title ?? lineItem.entityId,
385
397
  quantity: lineItem.quantity,
386
398
  unitPrice: lineItem.resolvedUnitPrice ?? 0,
@@ -392,7 +404,7 @@ export const calculateTax: BeforeHook<CheckoutData> = async ({
392
404
  ...(data.shippingAddress !== undefined
393
405
  ? { toAddress: data.shippingAddress }
394
406
  : {}),
395
- });
407
+ }, resolveOrgId(context.actor ?? null));
396
408
 
397
409
  if (!calculated.ok) {
398
410
  throw new CommerceValidationError(
@@ -400,6 +412,14 @@ export const calculateTax: BeforeHook<CheckoutData> = async ({
400
412
  );
401
413
  }
402
414
 
415
+ // Product tax classes return per-line results — store them so the order's
416
+ // line items carry taxAmount (issue #57). Lines arrive in request order.
417
+ if (calculated.value.lines) {
418
+ for (const [i, line] of calculated.value.lines.entries()) {
419
+ if (data.lineItems[i]) data.lineItems[i]!.taxAmount = line.taxAmount;
420
+ }
421
+ }
422
+
403
423
  data.taxTotal = Math.max(0, Math.round(calculated.value.amountToCollect));
404
424
  recalculateTotals(data);
405
425
  return data;
@@ -421,6 +441,7 @@ export const calculateShipping: BeforeHook<CheckoutData> = async ({
421
441
  currency: string;
422
442
  address?: ShippingAddress;
423
443
  isFreeShipping?: boolean;
444
+ orgId?: string;
424
445
  }): Promise<
425
446
  | {
426
447
  ok: true;
@@ -441,6 +462,7 @@ export const calculateShipping: BeforeHook<CheckoutData> = async ({
441
462
  })),
442
463
  subtotalAfterDiscount: Math.max(0, data.subtotal - data.discountTotal),
443
464
  currency: data.currency,
465
+ orgId: resolveOrgId(context.actor ?? null),
444
466
  ...(data.shippingAddress !== undefined
445
467
  ? { address: data.shippingAddress }
446
468
  : {}),
@@ -544,16 +566,19 @@ export const reserveInventory: AfterHook<OrderResult> = async ({ result, context
544
566
  quantity: number;
545
567
  orderId: string;
546
568
  performedBy: string;
547
- }): Promise<unknown>;
569
+ }, actor?: unknown): Promise<unknown>;
548
570
  };
549
571
  for (const lineItem of result.lineItems ?? []) {
550
- await inventory.reserve({
551
- entityId: lineItem.entityId,
552
- ...(lineItem.variantId != null ? { variantId: lineItem.variantId } : {}),
553
- quantity: lineItem.quantity,
554
- orderId: result.id,
555
- performedBy: context.actor?.userId ?? "system",
556
- });
572
+ await inventory.reserve(
573
+ {
574
+ entityId: lineItem.entityId,
575
+ ...(lineItem.variantId != null ? { variantId: lineItem.variantId } : {}),
576
+ quantity: lineItem.quantity,
577
+ orderId: result.id,
578
+ performedBy: context.actor?.userId ?? "system",
579
+ },
580
+ context.actor,
581
+ );
557
582
  }
558
583
  };
559
584
 
package/src/index.ts CHANGED
@@ -15,6 +15,7 @@ export type {
15
15
  } from "./kernel/plugin/manifest.js";
16
16
 
17
17
  export { router } from "./interfaces/rest/router.js";
18
+ export { requirePerm } from "./interfaces/rest/utils.js";
18
19
  export { webhookRouter, type WebhookRouterResult } from "./interfaces/rest/webhook-router.js";
19
20
  export { isPrivateUrl, isPrivateIp } from "./modules/webhooks/ssrf-guard.js";
20
21
  export { createServer } from "./runtime/server.js";
@@ -4,6 +4,7 @@ import { sql } from "drizzle-orm";
4
4
  import type { Kernel } from "../../runtime/kernel.js";
5
5
  import type { AppEnv } from "./utils.js";
6
6
  import { mapErrorToResponse } from "../../kernel/error-mapper.js";
7
+ import { analyticsRoutes } from "./routes/analytics.js";
7
8
  import { catalogRoutes } from "./routes/catalog.js";
8
9
  import { inventoryRoutes } from "./routes/inventory.js";
9
10
  import { mediaRoutes } from "./routes/media.js";
@@ -19,7 +20,12 @@ import { auditRoutes } from "./routes/audit.js";
19
20
  import { adminJobRoutes } from "./routes/admin-jobs.js";
20
21
  import { compensationFailureAdminRoutes } from "./routes/admin/compensation-failures.js";
21
22
  import { adminPermissionsRoutes } from "./routes/admin/permissions.js";
23
+ import { adminStaffRoutes } from "./routes/admin/staff.js";
22
24
  import { customerRoutes } from "./routes/customers.js";
25
+ import { documentRoutes } from "./routes/documents.js";
26
+ import { settingsRoutes } from "./routes/settings.js";
27
+ import { shippingRoutes } from "./routes/shipping.js";
28
+ import { taxRoutes } from "./routes/tax.js";
23
29
  export function createRestRoutes(kernel: Kernel) {
24
30
  const router = new OpenAPIHono<AppEnv>({
25
31
  // Standardize Zod validation error responses across all routes
@@ -72,9 +78,14 @@ export function createRestRoutes(kernel: Kernel) {
72
78
  router.route("/carts", cartRoutes(kernel));
73
79
  router.route("/checkout", checkoutRoutes(kernel));
74
80
  router.route("/orders", orderRoutes(kernel));
81
+ router.route("/orders", documentRoutes(kernel));
75
82
  router.route("/payments", paymentRoutes(kernel));
76
83
  router.route("/webhooks", webhookRoutes(kernel));
77
84
  router.route("/pricing", pricingRoutes(kernel));
85
+ router.route("/shipping", shippingRoutes(kernel));
86
+ router.route("/tax", taxRoutes(kernel));
87
+ router.route("/settings", settingsRoutes(kernel));
88
+ router.route("/analytics", analyticsRoutes(kernel));
78
89
  router.route("/promotions", promotionRoutes(kernel));
79
90
  router.route("/search", searchRoutes(kernel));
80
91
  router.route("/customers", customerRoutes(kernel));
@@ -82,6 +93,7 @@ export function createRestRoutes(kernel: Kernel) {
82
93
  router.route("/admin", adminJobRoutes(kernel));
83
94
  router.route("/admin", compensationFailureAdminRoutes(kernel));
84
95
  router.route("/admin", adminPermissionsRoutes(kernel));
96
+ router.route("/admin", adminStaffRoutes(kernel));
85
97
 
86
98
  // API Reference (Scalar) — disabled in production unless config.exposeOpenApiSpec is true
87
99
  const exposeSpec = kernel.config.exposeOpenApiSpec ?? (process.env.NODE_ENV !== "production");
@@ -0,0 +1,260 @@
1
+ import { OpenAPIHono } from "@hono/zod-openapi";
2
+ import { eq, and } from "drizzle-orm";
3
+ import type { Kernel } from "../../../../runtime/kernel.js";
4
+ import type { CommerceConfig } from "../../../../config/types.js";
5
+ import type { DrizzleDatabase } from "../../../../kernel/database/drizzle-db.js";
6
+ import { member, user, invitation } from "../../../../auth/auth-schema.js";
7
+ import { resolveOrgId } from "../../../../auth/org.js";
8
+ import { makeId } from "../../../../utils/id.js";
9
+ import {
10
+ listStaffRoute,
11
+ createStaffRoute,
12
+ inviteStaffRoute,
13
+ listStaffInvitationsRoute,
14
+ listStaffRolesRoute,
15
+ updateStaffRoleRoute,
16
+ revokeStaffRoute,
17
+ } from "../../schemas/admin-staff.js";
18
+ import { type AppEnv, requirePerm } from "../../utils.js";
19
+
20
+ /**
21
+ * Admin staff / RBAC surface (issue #46).
22
+ *
23
+ * Surfaces the Better Auth `member` table as first-class admin REST:
24
+ * list staff, add an existing user with a role, invite by email, change
25
+ * role, revoke — plus the role → permission mapping roles resolve to.
26
+ */
27
+ export function adminStaffRoutes(kernel: Kernel) {
28
+ const router = new OpenAPIHono<AppEnv>();
29
+ const db = kernel.database.db as DrizzleDatabase;
30
+ const config = kernel.config as CommerceConfig;
31
+
32
+ router.use("/staff", requirePerm("staff:manage"));
33
+ router.use("/staff/:id", requirePerm("staff:manage"));
34
+ router.use("/staff/invitations", requirePerm("staff:manage"));
35
+ router.use("/staff/roles", requirePerm("staff:manage"));
36
+
37
+ function validRoles(): Set<string> {
38
+ return new Set([
39
+ ...Object.keys(config.auth?.roles ?? {}),
40
+ "owner",
41
+ "admin",
42
+ "member",
43
+ ]);
44
+ }
45
+
46
+ function invalidRole(c: { json: (d: unknown, s: number) => unknown }, role: string) {
47
+ return c.json(
48
+ {
49
+ error: {
50
+ code: "VALIDATION_FAILED",
51
+ message: `Unknown role "${role}". Valid roles: ${[...validRoles()].join(", ")}.`,
52
+ },
53
+ },
54
+ 422,
55
+ );
56
+ }
57
+
58
+ const BUILTIN_ROLE_RANK: Record<string, number> = {
59
+ owner: 3,
60
+ admin: 2,
61
+ };
62
+ const CUSTOM_ROLE_RANK = 1;
63
+
64
+ function roleRank(role: string): number {
65
+ return BUILTIN_ROLE_RANK[role] ?? CUSTOM_ROLE_RANK;
66
+ }
67
+
68
+ function canGrantRole(actorRole: string, targetRole: string): boolean {
69
+ return roleRank(actorRole) >= roleRank(targetRole);
70
+ }
71
+
72
+ function insufficientPrivilege(
73
+ c: { json: (d: unknown, s: number) => unknown },
74
+ targetRole: string,
75
+ ) {
76
+ return c.json(
77
+ {
78
+ error: {
79
+ code: "FORBIDDEN",
80
+ message: `Cannot assign role "${targetRole}": your role does not have sufficient privilege.`,
81
+ },
82
+ },
83
+ 403,
84
+ );
85
+ }
86
+
87
+ async function countOwners(orgId: string): Promise<number> {
88
+ const rows = await db
89
+ .select({ id: member.id })
90
+ .from(member)
91
+ .where(and(eq(member.organizationId, orgId), eq(member.role, "owner")));
92
+ return rows.length;
93
+ }
94
+
95
+ router.openapi(listStaffRoute, async (c) => {
96
+ const orgId = resolveOrgId(c.get("actor"));
97
+ const rows = await db
98
+ .select({
99
+ id: member.id,
100
+ userId: member.userId,
101
+ role: member.role,
102
+ createdAt: member.createdAt,
103
+ email: user.email,
104
+ name: user.name,
105
+ })
106
+ .from(member)
107
+ .leftJoin(user, eq(member.userId, user.id))
108
+ .where(eq(member.organizationId, orgId));
109
+ return c.json({ data: rows });
110
+ });
111
+
112
+ // @ts-expect-error -- openapi handler union return type
113
+ router.openapi(createStaffRoute, async (c) => {
114
+ const body = c.req.valid("json") as { userId: string; role: string };
115
+ const actor = c.get("actor");
116
+ const orgId = resolveOrgId(actor);
117
+
118
+ if (!validRoles().has(body.role)) return invalidRole(c, body.role);
119
+ if (!canGrantRole(actor!.role, body.role)) return insufficientPrivilege(c, body.role);
120
+
121
+ const users = await db.select().from(user).where(eq(user.id, body.userId));
122
+ if (users.length === 0) {
123
+ return c.json({ error: { code: "NOT_FOUND", message: "User not found." } }, 404);
124
+ }
125
+
126
+ const existing = await db
127
+ .select({ id: member.id })
128
+ .from(member)
129
+ .where(and(eq(member.organizationId, orgId), eq(member.userId, body.userId)));
130
+ if (existing.length > 0) {
131
+ return c.json(
132
+ { error: { code: "VALIDATION_FAILED", message: "User is already a member of this organization." } },
133
+ 422,
134
+ );
135
+ }
136
+
137
+ const rows = await db
138
+ .insert(member)
139
+ .values({
140
+ id: makeId(),
141
+ organizationId: orgId,
142
+ userId: body.userId,
143
+ role: body.role,
144
+ createdAt: new Date(),
145
+ })
146
+ .returning();
147
+ return c.json({ data: rows[0] }, 201);
148
+ });
149
+
150
+ // @ts-expect-error -- openapi handler union return type
151
+ router.openapi(inviteStaffRoute, async (c) => {
152
+ const body = c.req.valid("json") as { email: string; role: string };
153
+ const actor = c.get("actor");
154
+ const orgId = resolveOrgId(actor);
155
+
156
+ if (!validRoles().has(body.role)) return invalidRole(c, body.role);
157
+
158
+ const rows = await db
159
+ .insert(invitation)
160
+ .values({
161
+ id: makeId(),
162
+ organizationId: orgId,
163
+ email: body.email,
164
+ role: body.role,
165
+ status: "pending",
166
+ expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
167
+ inviterId: actor!.userId,
168
+ })
169
+ .returning();
170
+ return c.json({ data: rows[0] }, 201);
171
+ });
172
+
173
+ router.openapi(listStaffInvitationsRoute, async (c) => {
174
+ const orgId = resolveOrgId(c.get("actor"));
175
+ const rows = await db
176
+ .select()
177
+ .from(invitation)
178
+ .where(and(eq(invitation.organizationId, orgId), eq(invitation.status, "pending")));
179
+ return c.json({ data: rows });
180
+ });
181
+
182
+ router.openapi(listStaffRolesRoute, async (c) => {
183
+ const roles = config.auth?.roles ?? {};
184
+ const data = Object.entries(roles).map(([role, def]) => ({
185
+ role,
186
+ permissions: def.permissions,
187
+ }));
188
+ return c.json({ data });
189
+ });
190
+
191
+ // @ts-expect-error -- openapi handler union return type
192
+ router.openapi(updateStaffRoleRoute, async (c) => {
193
+ const body = c.req.valid("json") as { role: string };
194
+ const actor = c.get("actor");
195
+ const orgId = resolveOrgId(actor);
196
+ const id = c.req.param("id");
197
+
198
+ if (!validRoles().has(body.role)) return invalidRole(c, body.role);
199
+ if (!canGrantRole(actor!.role, body.role)) return insufficientPrivilege(c, body.role);
200
+
201
+ const rows = await db
202
+ .select()
203
+ .from(member)
204
+ .where(and(eq(member.organizationId, orgId), eq(member.id, id)));
205
+ const target = rows[0];
206
+ if (!target) {
207
+ return c.json({ error: { code: "NOT_FOUND", message: "Staff member not found." } }, 404);
208
+ }
209
+
210
+ // SEC-18/R-02: the actor must also outrank (or equal) the target's CURRENT
211
+ // role, else an admin could demote an owner they do not outrank.
212
+ if (!canGrantRole(actor!.role, target.role)) return insufficientPrivilege(c, target.role);
213
+
214
+ if (target.role === "owner" && body.role !== "owner" && (await countOwners(orgId)) <= 1) {
215
+ return c.json(
216
+ { error: { code: "VALIDATION_FAILED", message: "Cannot demote the organization's last owner." } },
217
+ 422,
218
+ );
219
+ }
220
+
221
+ const updated = await db
222
+ .update(member)
223
+ .set({ role: body.role })
224
+ .where(eq(member.id, id))
225
+ .returning();
226
+ return c.json({ data: updated[0] });
227
+ });
228
+
229
+ // @ts-expect-error -- openapi handler union return type
230
+ router.openapi(revokeStaffRoute, async (c) => {
231
+ const actor = c.get("actor");
232
+ const orgId = resolveOrgId(actor);
233
+ const id = c.req.param("id");
234
+
235
+ const rows = await db
236
+ .select()
237
+ .from(member)
238
+ .where(and(eq(member.organizationId, orgId), eq(member.id, id)));
239
+ const target = rows[0];
240
+ if (!target) {
241
+ return c.json({ error: { code: "NOT_FOUND", message: "Staff member not found." } }, 404);
242
+ }
243
+
244
+ // SEC-18/R-02: the actor must outrank (or equal) the target's current role
245
+ // to revoke it — an admin cannot revoke an owner.
246
+ if (!canGrantRole(actor!.role, target.role)) return insufficientPrivilege(c, target.role);
247
+
248
+ if (target.role === "owner" && (await countOwners(orgId)) <= 1) {
249
+ return c.json(
250
+ { error: { code: "VALIDATION_FAILED", message: "Cannot revoke the organization's last owner." } },
251
+ 422,
252
+ );
253
+ }
254
+
255
+ await db.delete(member).where(eq(member.id, id));
256
+ return c.json({ data: { deleted: true } });
257
+ });
258
+
259
+ return router;
260
+ }