@porulle/core 0.11.0 → 0.14.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 (300) hide show
  1. package/dist/auth/actor.d.ts +10 -0
  2. package/dist/auth/actor.d.ts.map +1 -0
  3. package/dist/auth/actor.js +91 -0
  4. package/dist/auth/auth-schema-guard.d.ts +2444 -0
  5. package/dist/auth/auth-schema-guard.d.ts.map +1 -0
  6. package/dist/auth/auth-schema-guard.js +113 -0
  7. package/dist/auth/auth-schema.d.ts +228 -0
  8. package/dist/auth/auth-schema.d.ts.map +1 -1
  9. package/dist/auth/auth-schema.js +24 -2
  10. package/dist/auth/middleware.d.ts +1 -0
  11. package/dist/auth/middleware.d.ts.map +1 -1
  12. package/dist/auth/middleware.js +16 -92
  13. package/dist/auth/org.d.ts +8 -2
  14. package/dist/auth/org.d.ts.map +1 -1
  15. package/dist/auth/org.js +12 -4
  16. package/dist/auth/organization-guard.d.ts +5 -0
  17. package/dist/auth/organization-guard.d.ts.map +1 -0
  18. package/dist/auth/organization-guard.js +124 -0
  19. package/dist/auth/permissions.d.ts +10 -0
  20. package/dist/auth/permissions.d.ts.map +1 -1
  21. package/dist/auth/permissions.js +31 -7
  22. package/dist/auth/role-authority.d.ts +44 -0
  23. package/dist/auth/role-authority.d.ts.map +1 -0
  24. package/dist/auth/role-authority.js +85 -0
  25. package/dist/auth/setup.d.ts.map +1 -1
  26. package/dist/auth/setup.js +19 -1
  27. package/dist/auth/strict-org-resolution.d.ts.map +1 -1
  28. package/dist/auth/strict-org-resolution.js +9 -1
  29. package/dist/auth/types.d.ts +1 -1
  30. package/dist/auth/types.d.ts.map +1 -1
  31. package/dist/config/defaults.d.ts.map +1 -1
  32. package/dist/config/defaults.js +2 -0
  33. package/dist/config/types.d.ts +32 -4
  34. package/dist/config/types.d.ts.map +1 -1
  35. package/dist/hooks/checkout.d.ts.map +1 -1
  36. package/dist/hooks/checkout.js +10 -9
  37. package/dist/index.d.ts +7 -5
  38. package/dist/index.d.ts.map +1 -1
  39. package/dist/index.js +5 -3
  40. package/dist/interfaces/rest/audit-middleware.d.ts.map +1 -1
  41. package/dist/interfaces/rest/audit-middleware.js +2 -0
  42. package/dist/interfaces/rest/customer-portal.d.ts.map +1 -1
  43. package/dist/interfaces/rest/customer-portal.js +9 -8
  44. package/dist/interfaces/rest/route-coverage.d.ts +14 -0
  45. package/dist/interfaces/rest/route-coverage.d.ts.map +1 -0
  46. package/dist/interfaces/rest/route-coverage.js +81 -0
  47. package/dist/interfaces/rest/router.d.ts +7 -2
  48. package/dist/interfaces/rest/router.d.ts.map +1 -1
  49. package/dist/interfaces/rest/router.js +8 -4
  50. package/dist/interfaces/rest/routes/admin/compensation-failures.d.ts.map +1 -1
  51. package/dist/interfaces/rest/routes/admin/compensation-failures.js +7 -5
  52. package/dist/interfaces/rest/routes/admin/custom-field-proposals.js +2 -2
  53. package/dist/interfaces/rest/routes/admin/permissions.d.ts +1 -1
  54. package/dist/interfaces/rest/routes/admin/permissions.d.ts.map +1 -1
  55. package/dist/interfaces/rest/routes/admin/permissions.js +2 -0
  56. package/dist/interfaces/rest/routes/admin/staff.d.ts.map +1 -1
  57. package/dist/interfaces/rest/routes/admin/staff.js +126 -66
  58. package/dist/interfaces/rest/routes/admin-jobs.js +3 -3
  59. package/dist/interfaces/rest/routes/audit.js +3 -3
  60. package/dist/interfaces/rest/routes/carts.d.ts.map +1 -1
  61. package/dist/interfaces/rest/routes/carts.js +13 -10
  62. package/dist/interfaces/rest/routes/catalog.d.ts.map +1 -1
  63. package/dist/interfaces/rest/routes/catalog.js +10 -2
  64. package/dist/interfaces/rest/routes/checkout.d.ts.map +1 -1
  65. package/dist/interfaces/rest/routes/checkout.js +30 -35
  66. package/dist/interfaces/rest/routes/documents.d.ts.map +1 -1
  67. package/dist/interfaces/rest/routes/documents.js +10 -5
  68. package/dist/interfaces/rest/routes/inventory.d.ts.map +1 -1
  69. package/dist/interfaces/rest/routes/inventory.js +2 -0
  70. package/dist/interfaces/rest/routes/media.d.ts.map +1 -1
  71. package/dist/interfaces/rest/routes/media.js +7 -3
  72. package/dist/interfaces/rest/routes/orders.d.ts.map +1 -1
  73. package/dist/interfaces/rest/routes/orders.js +26 -8
  74. package/dist/interfaces/rest/routes/pricing.js +2 -2
  75. package/dist/interfaces/rest/routes/promotions.d.ts.map +1 -1
  76. package/dist/interfaces/rest/routes/promotions.js +13 -10
  77. package/dist/interfaces/rest/routes/search.d.ts.map +1 -1
  78. package/dist/interfaces/rest/routes/search.js +2 -1
  79. package/dist/interfaces/rest/schemas/carts.d.ts +7 -0
  80. package/dist/interfaces/rest/schemas/carts.d.ts.map +1 -1
  81. package/dist/interfaces/rest/schemas/customer-portal.d.ts +35 -0
  82. package/dist/interfaces/rest/schemas/customer-portal.d.ts.map +1 -1
  83. package/dist/interfaces/rest/schemas/orders.d.ts +204 -0
  84. package/dist/interfaces/rest/schemas/orders.d.ts.map +1 -1
  85. package/dist/interfaces/rest/schemas/responses.d.ts +71 -0
  86. package/dist/interfaces/rest/schemas/responses.d.ts.map +1 -1
  87. package/dist/interfaces/rest/utils.d.ts +15 -0
  88. package/dist/interfaces/rest/utils.d.ts.map +1 -1
  89. package/dist/interfaces/rest/utils.js +45 -2
  90. package/dist/kernel/compensation/executor.js +2 -2
  91. package/dist/kernel/database/plugin-types.d.ts +11 -0
  92. package/dist/kernel/database/plugin-types.d.ts.map +1 -1
  93. package/dist/kernel/database/scoped-db.d.ts +12 -0
  94. package/dist/kernel/database/scoped-db.d.ts.map +1 -1
  95. package/dist/kernel/database/scoped-db.js +12 -0
  96. package/dist/kernel/database/tx-context.d.ts +8 -0
  97. package/dist/kernel/database/tx-context.d.ts.map +1 -1
  98. package/dist/kernel/database/tx-context.js +13 -0
  99. package/dist/kernel/hooks/create-context.d.ts +2 -0
  100. package/dist/kernel/hooks/create-context.d.ts.map +1 -1
  101. package/dist/kernel/hooks/create-context.js +1 -0
  102. package/dist/kernel/hooks/types.d.ts +2 -0
  103. package/dist/kernel/hooks/types.d.ts.map +1 -1
  104. package/dist/kernel/plugin/manifest.d.ts.map +1 -1
  105. package/dist/kernel/plugin/manifest.js +9 -5
  106. package/dist/modules/analytics/service.js +2 -2
  107. package/dist/modules/audit/service.js +3 -3
  108. package/dist/modules/cart/repository/index.d.ts +13 -7
  109. package/dist/modules/cart/repository/index.d.ts.map +1 -1
  110. package/dist/modules/cart/repository/index.js +16 -10
  111. package/dist/modules/cart/schema.d.ts +17 -0
  112. package/dist/modules/cart/schema.d.ts.map +1 -1
  113. package/dist/modules/cart/schema.js +4 -0
  114. package/dist/modules/cart/service.d.ts +22 -9
  115. package/dist/modules/cart/service.d.ts.map +1 -1
  116. package/dist/modules/cart/service.js +57 -33
  117. package/dist/modules/catalog/brand-service.js +7 -7
  118. package/dist/modules/catalog/category-service.js +7 -7
  119. package/dist/modules/catalog/entity-service.d.ts +14 -7
  120. package/dist/modules/catalog/entity-service.d.ts.map +1 -1
  121. package/dist/modules/catalog/entity-service.js +109 -31
  122. package/dist/modules/catalog/read-policy.d.ts +9 -0
  123. package/dist/modules/catalog/read-policy.d.ts.map +1 -0
  124. package/dist/modules/catalog/read-policy.js +8 -0
  125. package/dist/modules/catalog/repository/index.d.ts +3 -2
  126. package/dist/modules/catalog/repository/index.d.ts.map +1 -1
  127. package/dist/modules/catalog/repository/index.js +14 -4
  128. package/dist/modules/catalog/service.d.ts +15 -13
  129. package/dist/modules/catalog/service.d.ts.map +1 -1
  130. package/dist/modules/catalog/service.js +25 -18
  131. package/dist/modules/customers/module.d.ts.map +1 -1
  132. package/dist/modules/customers/module.js +1 -0
  133. package/dist/modules/customers/service.d.ts +2 -0
  134. package/dist/modules/customers/service.d.ts.map +1 -1
  135. package/dist/modules/customers/service.js +21 -20
  136. package/dist/modules/documents/module.d.ts.map +1 -1
  137. package/dist/modules/documents/module.js +1 -0
  138. package/dist/modules/documents/service.d.ts +18 -4
  139. package/dist/modules/documents/service.d.ts.map +1 -1
  140. package/dist/modules/documents/service.js +54 -16
  141. package/dist/modules/fulfillment/module.d.ts.map +1 -1
  142. package/dist/modules/fulfillment/module.js +1 -0
  143. package/dist/modules/fulfillment/service.d.ts +2 -0
  144. package/dist/modules/fulfillment/service.d.ts.map +1 -1
  145. package/dist/modules/fulfillment/service.js +15 -6
  146. package/dist/modules/inventory/service.d.ts.map +1 -1
  147. package/dist/modules/inventory/service.js +15 -14
  148. package/dist/modules/media/service.js +6 -6
  149. package/dist/modules/orders/guest-access.d.ts +27 -0
  150. package/dist/modules/orders/guest-access.d.ts.map +1 -0
  151. package/dist/modules/orders/guest-access.js +46 -0
  152. package/dist/modules/orders/module.d.ts.map +1 -1
  153. package/dist/modules/orders/module.js +1 -0
  154. package/dist/modules/orders/quote.d.ts.map +1 -1
  155. package/dist/modules/orders/quote.js +1 -0
  156. package/dist/modules/orders/repository/index.d.ts +1 -1
  157. package/dist/modules/orders/repository/index.d.ts.map +1 -1
  158. package/dist/modules/orders/repository/index.js +2 -2
  159. package/dist/modules/orders/schema.d.ts +17 -0
  160. package/dist/modules/orders/schema.d.ts.map +1 -1
  161. package/dist/modules/orders/schema.js +5 -3
  162. package/dist/modules/orders/service.d.ts +16 -7
  163. package/dist/modules/orders/service.d.ts.map +1 -1
  164. package/dist/modules/orders/service.js +184 -55
  165. package/dist/modules/pricing/module.d.ts.map +1 -1
  166. package/dist/modules/pricing/module.js +1 -0
  167. package/dist/modules/pricing/service.d.ts +2 -0
  168. package/dist/modules/pricing/service.d.ts.map +1 -1
  169. package/dist/modules/pricing/service.js +12 -11
  170. package/dist/modules/promotions/module.d.ts.map +1 -1
  171. package/dist/modules/promotions/module.js +1 -0
  172. package/dist/modules/promotions/service.d.ts +3 -0
  173. package/dist/modules/promotions/service.d.ts.map +1 -1
  174. package/dist/modules/promotions/service.js +19 -11
  175. package/dist/modules/search/adapter.d.ts +11 -0
  176. package/dist/modules/search/adapter.d.ts.map +1 -1
  177. package/dist/modules/search/module.d.ts.map +1 -1
  178. package/dist/modules/search/module.js +4 -1
  179. package/dist/modules/search/service.d.ts +1 -0
  180. package/dist/modules/search/service.d.ts.map +1 -1
  181. package/dist/modules/search/service.js +57 -6
  182. package/dist/modules/settings/module.d.ts.map +1 -1
  183. package/dist/modules/settings/module.js +1 -0
  184. package/dist/modules/settings/service.d.ts +3 -0
  185. package/dist/modules/settings/service.d.ts.map +1 -1
  186. package/dist/modules/settings/service.js +6 -4
  187. package/dist/modules/shipping/service.js +9 -9
  188. package/dist/modules/tax/module.d.ts.map +1 -1
  189. package/dist/modules/tax/module.js +1 -0
  190. package/dist/modules/tax/service.d.ts +3 -0
  191. package/dist/modules/tax/service.d.ts.map +1 -1
  192. package/dist/modules/tax/service.js +11 -9
  193. package/dist/modules/webhooks/hook.js +2 -2
  194. package/dist/modules/webhooks/module.d.ts.map +1 -1
  195. package/dist/modules/webhooks/module.js +1 -0
  196. package/dist/modules/webhooks/service.d.ts +2 -0
  197. package/dist/modules/webhooks/service.d.ts.map +1 -1
  198. package/dist/modules/webhooks/service.js +4 -4
  199. package/dist/runtime/server.d.ts.map +1 -1
  200. package/dist/runtime/server.js +28 -2
  201. package/dist/test-utils/create-pglite-adapter.d.ts.map +1 -1
  202. package/dist/test-utils/create-pglite-adapter.js +35 -13
  203. package/dist/test-utils/create-test-config.d.ts.map +1 -1
  204. package/dist/test-utils/create-test-config.js +4 -0
  205. package/dist/test-utils/rest-api-test-utils.d.ts.map +1 -1
  206. package/dist/test-utils/rest-api-test-utils.js +2 -0
  207. package/dist/test-utils/test-actors.js +2 -2
  208. package/dist/utils/id.d.ts +2 -0
  209. package/dist/utils/id.d.ts.map +1 -1
  210. package/dist/utils/id.js +11 -0
  211. package/package.json +6 -5
  212. package/src/auth/actor.ts +110 -0
  213. package/src/auth/auth-schema-guard.ts +143 -0
  214. package/src/auth/auth-schema.ts +50 -19
  215. package/src/auth/middleware.ts +25 -101
  216. package/src/auth/org.ts +15 -3
  217. package/src/auth/organization-guard.ts +158 -0
  218. package/src/auth/permissions.ts +31 -8
  219. package/src/auth/role-authority.ts +107 -0
  220. package/src/auth/setup.ts +19 -1
  221. package/src/auth/strict-org-resolution.ts +7 -1
  222. package/src/auth/types.ts +1 -1
  223. package/src/config/defaults.ts +2 -0
  224. package/src/config/types.ts +31 -4
  225. package/src/hooks/checkout.ts +19 -12
  226. package/src/index.ts +21 -6
  227. package/src/interfaces/rest/audit-middleware.ts +2 -0
  228. package/src/interfaces/rest/customer-portal.ts +31 -24
  229. package/src/interfaces/rest/route-coverage.ts +103 -0
  230. package/src/interfaces/rest/router.ts +16 -10
  231. package/src/interfaces/rest/routes/admin/compensation-failures.ts +8 -5
  232. package/src/interfaces/rest/routes/admin/custom-field-proposals.ts +2 -2
  233. package/src/interfaces/rest/routes/admin/permissions.ts +3 -1
  234. package/src/interfaces/rest/routes/admin/staff.ts +161 -72
  235. package/src/interfaces/rest/routes/admin-jobs.ts +3 -3
  236. package/src/interfaces/rest/routes/audit.ts +3 -3
  237. package/src/interfaces/rest/routes/carts.ts +17 -7
  238. package/src/interfaces/rest/routes/catalog.ts +11 -0
  239. package/src/interfaces/rest/routes/checkout.ts +41 -38
  240. package/src/interfaces/rest/routes/documents.ts +15 -1
  241. package/src/interfaces/rest/routes/inventory.ts +2 -0
  242. package/src/interfaces/rest/routes/media.ts +8 -3
  243. package/src/interfaces/rest/routes/orders.ts +27 -8
  244. package/src/interfaces/rest/routes/pricing.ts +2 -2
  245. package/src/interfaces/rest/routes/promotions.ts +12 -9
  246. package/src/interfaces/rest/routes/search.ts +3 -1
  247. package/src/interfaces/rest/utils.ts +61 -2
  248. package/src/kernel/compensation/executor.ts +2 -2
  249. package/src/kernel/database/plugin-types.ts +11 -0
  250. package/src/kernel/database/scoped-db.ts +12 -0
  251. package/src/kernel/database/tx-context.ts +25 -0
  252. package/src/kernel/hooks/create-context.ts +3 -0
  253. package/src/kernel/hooks/types.ts +2 -0
  254. package/src/kernel/plugin/manifest.ts +9 -5
  255. package/src/modules/analytics/service.ts +2 -2
  256. package/src/modules/audit/service.ts +3 -3
  257. package/src/modules/cart/repository/index.ts +22 -8
  258. package/src/modules/cart/schema.ts +4 -0
  259. package/src/modules/cart/service.ts +62 -27
  260. package/src/modules/catalog/brand-service.ts +7 -7
  261. package/src/modules/catalog/category-service.ts +7 -7
  262. package/src/modules/catalog/entity-service.ts +117 -35
  263. package/src/modules/catalog/read-policy.ts +16 -0
  264. package/src/modules/catalog/repository/index.ts +19 -3
  265. package/src/modules/catalog/service.ts +46 -31
  266. package/src/modules/customers/module.ts +2 -0
  267. package/src/modules/customers/service.ts +23 -22
  268. package/src/modules/documents/module.ts +2 -0
  269. package/src/modules/documents/service.ts +74 -11
  270. package/src/modules/fulfillment/module.ts +2 -0
  271. package/src/modules/fulfillment/service.ts +17 -6
  272. package/src/modules/inventory/service.ts +15 -24
  273. package/src/modules/media/service.ts +6 -6
  274. package/src/modules/orders/guest-access.ts +55 -0
  275. package/src/modules/orders/module.ts +1 -0
  276. package/src/modules/orders/quote.ts +1 -0
  277. package/src/modules/orders/repository/index.ts +2 -0
  278. package/src/modules/orders/schema.ts +5 -3
  279. package/src/modules/orders/service.ts +302 -52
  280. package/src/modules/pricing/module.ts +2 -0
  281. package/src/modules/pricing/service.ts +14 -16
  282. package/src/modules/promotions/module.ts +2 -0
  283. package/src/modules/promotions/service.ts +25 -16
  284. package/src/modules/search/adapter.ts +9 -0
  285. package/src/modules/search/module.ts +5 -1
  286. package/src/modules/search/service.ts +55 -6
  287. package/src/modules/settings/module.ts +2 -0
  288. package/src/modules/settings/service.ts +8 -4
  289. package/src/modules/shipping/service.ts +9 -9
  290. package/src/modules/tax/module.ts +1 -0
  291. package/src/modules/tax/service.ts +13 -9
  292. package/src/modules/webhooks/hook.ts +2 -2
  293. package/src/modules/webhooks/module.ts +2 -0
  294. package/src/modules/webhooks/service.ts +6 -4
  295. package/src/runtime/server.ts +41 -2
  296. package/src/test-utils/create-pglite-adapter.ts +34 -12
  297. package/src/test-utils/create-test-config.ts +10 -6
  298. package/src/test-utils/rest-api-test-utils.ts +2 -0
  299. package/src/test-utils/test-actors.ts +2 -2
  300. package/src/utils/id.ts +20 -0
@@ -4,6 +4,7 @@ import {
4
4
  timestamp,
5
5
  boolean,
6
6
  integer,
7
+ uniqueIndex,
7
8
  } from "drizzle-orm/pg-core";
8
9
 
9
10
  export const user = pgTable("user", {
@@ -19,6 +20,12 @@ export const user = pgTable("user", {
19
20
  .notNull(),
20
21
  vendorId: text("vendor_id"),
21
22
  posOperatorPin: text("pos_operator_pin"),
23
+ // Contributed by the twoFactor and phoneNumber plugins. Declared
24
+ // unconditionally: the plugins are config-gated, but one shipped schema has
25
+ // to satisfy every configuration a merchant can choose.
26
+ twoFactorEnabled: boolean("two_factor_enabled").default(false),
27
+ phoneNumber: text("phone_number").unique(),
28
+ phoneNumberVerified: boolean("phone_number_verified").default(false),
22
29
  });
23
30
 
24
31
  export const session = pgTable("session", {
@@ -37,25 +44,35 @@ export const session = pgTable("session", {
37
44
  activeOrganizationId: text("active_organization_id"),
38
45
  });
39
46
 
40
- export const account = pgTable("account", {
41
- id: text("id").primaryKey(),
42
- accountId: text("account_id").notNull(),
43
- providerId: text("provider_id").notNull(),
44
- userId: text("user_id")
45
- .notNull()
46
- .references(() => user.id, { onDelete: "cascade" }),
47
- accessToken: text("access_token"),
48
- refreshToken: text("refresh_token"),
49
- idToken: text("id_token"),
50
- accessTokenExpiresAt: timestamp("access_token_expires_at"),
51
- refreshTokenExpiresAt: timestamp("refresh_token_expires_at"),
52
- scope: text("scope"),
53
- password: text("password"),
54
- createdAt: timestamp("created_at").defaultNow().notNull(),
55
- updatedAt: timestamp("updated_at")
56
- .$onUpdate(() => /* @__PURE__ */ new Date())
57
- .notNull(),
58
- });
47
+ export const account = pgTable(
48
+ "account",
49
+ {
50
+ id: text("id").primaryKey(),
51
+ issuer: text("issuer").notNull(),
52
+ accountId: text("account_id").notNull(),
53
+ providerId: text("provider_id").notNull(),
54
+ userId: text("user_id")
55
+ .notNull()
56
+ .references(() => user.id, { onDelete: "cascade" }),
57
+ accessToken: text("access_token"),
58
+ refreshToken: text("refresh_token"),
59
+ idToken: text("id_token"),
60
+ accessTokenExpiresAt: timestamp("access_token_expires_at"),
61
+ refreshTokenExpiresAt: timestamp("refresh_token_expires_at"),
62
+ scope: text("scope"),
63
+ password: text("password"),
64
+ createdAt: timestamp("created_at").defaultNow().notNull(),
65
+ updatedAt: timestamp("updated_at")
66
+ .$onUpdate(() => /* @__PURE__ */ new Date())
67
+ .notNull(),
68
+ },
69
+ (table) => [
70
+ uniqueIndex("account_issuer_account_id_unique").on(
71
+ table.issuer,
72
+ table.accountId,
73
+ ),
74
+ ],
75
+ );
59
76
 
60
77
  export const verification = pgTable("verification", {
61
78
  id: text("id").primaryKey(),
@@ -136,4 +153,18 @@ export const jwks = pgTable("jwks", {
136
153
  privateKey: text("private_key").notNull(),
137
154
  createdAt: timestamp("created_at").notNull(),
138
155
  expiresAt: timestamp("expires_at"),
156
+ alg: text("alg"),
157
+ crv: text("crv"),
158
+ });
159
+
160
+ export const twoFactor = pgTable("twoFactor", {
161
+ id: text("id").primaryKey(),
162
+ secret: text("secret").notNull(),
163
+ backupCodes: text("backup_codes").notNull(),
164
+ userId: text("user_id")
165
+ .notNull()
166
+ .references(() => user.id, { onDelete: "cascade" }),
167
+ verified: boolean("verified").default(false),
168
+ failedVerificationCount: integer("failed_verification_count").default(0),
169
+ lockedUntil: timestamp("locked_until"),
139
170
  });
@@ -1,40 +1,23 @@
1
1
  import type { MiddlewareHandler } from "hono";
2
- import type { AuthSessionLike, CommerceConfig } from "../config/types.js";
2
+ import type { CommerceConfig } from "../config/types.js";
3
3
  import type { Actor } from "./types.js";
4
4
  import type { AuthInstance } from "./setup.js";
5
+ import { getCustomerPermissions, resolveActor } from "./actor.js";
5
6
  import { DEFAULT_ORG_ID } from "./org.js";
6
7
  import { isStrictOrgResolution } from "./strict-org-resolution.js";
7
8
 
8
- const DEFAULT_CUSTOMER_PERMISSIONS = [
9
- "catalog:read",
10
- "cart:create",
11
- "cart:read",
12
- "cart:update",
13
- "orders:create",
14
- "orders:read:own",
15
- "customers:read:self",
16
- "customers:update:self",
17
- ] as const;
18
-
19
- function getCustomerPermissions(config: CommerceConfig): string[] {
20
- return config.auth?.customerPermissions ?? [...DEFAULT_CUSTOMER_PERMISSIONS];
9
+ function emptyToNull(value: string | null | undefined): string | null {
10
+ return value == null || value === "" ? null : value;
21
11
  }
22
12
 
13
+ // Exported so the storefront contract can be pinned by a test: an anonymous
14
+ // visitor resolved by `storeResolver` gets these permissions, so dropping
15
+ // `catalog:read` here would 401 every public storefront read.
16
+ export { DEFAULT_CUSTOMER_PERMISSIONS } from "./actor.js";
17
+
23
18
  const LEGACY_STORE_RESOLVER_WARN_COOLDOWN_MS = 60_000;
24
19
  let lastLegacyStoreResolverWarnAt = 0;
25
20
 
26
- function resolvePermissions(
27
- session: AuthSessionLike,
28
- config: CommerceConfig,
29
- ): string[] {
30
- const role = session.session.activeOrganizationRole;
31
- if (!role) {
32
- return getCustomerPermissions(config);
33
- }
34
- const roleConfig = config.auth?.roles?.[role];
35
- return roleConfig ? roleConfig.permissions : [];
36
- }
37
-
38
21
  export function authMiddleware(
39
22
  auth: AuthInstance,
40
23
  config: CommerceConfig,
@@ -60,77 +43,14 @@ export function authMiddleware(
60
43
  }
61
44
  }
62
45
 
63
- const session = (await auth.api.getSession({
64
- headers: c.req.raw.headers,
65
- })) as AuthSessionLike | null;
66
-
67
- if (session) {
68
- // Better Auth's session stores activeOrganizationId, but often not the role.
69
- // For single-store apps (org_default), users may never call set-active,
70
- // so activeOrganizationId can be null even for valid org members.
71
- let role = session.session.activeOrganizationRole as string | undefined;
72
- let orgId = session.session.activeOrganizationId as string | null;
73
-
74
- // If no active org, try to resolve the user's membership in org_default.
75
- // This handles the common case where the user is a member but hasn't
76
- // called organization/set-active (single-store apps, seed scripts, tests).
77
- if (!role && auth.api.getFullOrganization) {
78
- try {
79
- const org = await auth.api.getFullOrganization({
80
- query: { organizationId: orgId ?? defaultOrgId },
81
- headers: c.req.raw.headers,
82
- });
83
- if (org?.members) {
84
- const membership = org.members.find(
85
- (m) => m.userId === session.user.id,
86
- );
87
- if (membership) {
88
- role = membership.role;
89
- orgId = orgId ?? defaultOrgId;
90
- }
91
- }
92
- } catch {
93
- // fall through — treat as customer
94
- }
95
- }
96
-
97
- // Also try getActiveMemberRole if active org is set
98
- if (!role && orgId && auth.api.getActiveMemberRole) {
99
- try {
100
- const roleResult = await auth.api.getActiveMemberRole({
101
- headers: c.req.raw.headers,
102
- });
103
- role = (roleResult as Record<string, unknown>)?.role as string | undefined;
104
- } catch {
105
- // fall through — treat as customer
106
- }
107
- }
108
-
109
- // For customers without org membership, resolve the store from the request.
110
- // This enables multi-store SaaS where each storefront is a different org.
111
- if (!orgId && config.auth?.storeResolver) {
112
- try {
113
- const resolved = await config.auth.storeResolver(c.req.raw);
114
- if (resolved) orgId = resolved;
115
- } catch {
116
- // fall through — use defaultOrgId
117
- }
118
- }
119
-
120
- const enrichedSession = {
121
- ...session,
122
- session: { ...session.session, activeOrganizationRole: role ?? null },
123
- };
124
- c.set("actor", {
125
- type: "user",
126
- userId: session.user.id,
127
- email: session.user.email ?? null,
128
- name: session.user.name ?? "User",
129
- vendorId: session.user.vendorId ?? null,
130
- organizationId: orgId ?? defaultOrgId,
131
- role: role ?? "customer",
132
- permissions: resolvePermissions(enrichedSession, config),
133
- } satisfies Actor);
46
+ const actor = await resolveActor(
47
+ c.req.raw.headers,
48
+ auth,
49
+ config,
50
+ c.req.raw,
51
+ );
52
+ if (actor) {
53
+ c.set("actor", actor);
134
54
  await next();
135
55
  return;
136
56
  }
@@ -192,9 +112,13 @@ export function authMiddleware(
192
112
  : null;
193
113
  const metaOrg = typeof meta?.organizationId === "string" ? meta.organizationId : undefined;
194
114
  const orgId = (metaOrg ?? apiKey.organizationId ?? defaultOrgId) as string;
195
- const userId = ((typeof meta?.operatorId === "string" ? meta.operatorId : undefined)
196
- ?? apiKey.referenceId
197
- ?? "") as string;
115
+ // No operator and no reference means the key carries no user
116
+ // identity. Leave it absent rather than substituting a shared
117
+ // placeholder, which would make every such key look like one person.
118
+ const userId = emptyToNull(
119
+ (typeof meta?.operatorId === "string" ? meta.operatorId : undefined) ??
120
+ (typeof apiKey.referenceId === "string" ? apiKey.referenceId : undefined),
121
+ );
198
122
 
199
123
  // Better Auth stores permissions as Record<string, string[]>
200
124
  // (e.g. {"catalog":["read","create"]}). Flatten to the
@@ -245,7 +169,7 @@ export function authMiddleware(
245
169
  // services can scope queries correctly.
246
170
  c.set("actor", {
247
171
  type: "user",
248
- userId: "anonymous",
172
+ userId: null,
249
173
  email: null,
250
174
  name: "Anonymous",
251
175
  vendorId: null,
package/src/auth/org.ts CHANGED
@@ -37,14 +37,26 @@ export function setBootDefaultOrgId(orgId: string): void {
37
37
  _bootDefaultOrgId = orgId;
38
38
  }
39
39
 
40
+ /**
41
+ * Resolves org using an explicit config default — not ambient boot state.
42
+ * Use when the caller holds CommerceConfig and may lack an actor organizationId.
43
+ */
44
+ export function resolveOrgIdForCommerce(
45
+ actor: unknown,
46
+ config?: CommerceConfig | null,
47
+ ): string {
48
+ return resolveOrgId(actor, config?.auth?.defaultOrganizationId, config ?? undefined);
49
+ }
50
+
40
51
  /**
41
52
  * Extracts the organization ID from an actor.
42
53
  *
43
54
  * Resolution order:
44
55
  * 1. Actor's organizationId (set by middleware from session/API key/storeResolver)
45
56
  * 2. Explicit defaultOrgId parameter (caller override)
46
- * 3. Boot-time default (from config.auth.defaultOrganizationId via setBootDefaultOrgId)
47
- * 4. Deprecated fallback to DEFAULT_ORG_ID (will be removed)
57
+ * 3. Strict check throw OrgResolutionError when enabled
58
+ * 4. Boot-time default (from config.auth.defaultOrganizationId via setBootDefaultOrgId)
59
+ * 5. Deprecated fallback to DEFAULT_ORG_ID (will be removed)
48
60
  */
49
61
  export function resolveOrgId(
50
62
  actor: unknown,
@@ -56,12 +68,12 @@ export function resolveOrgId(
56
68
  if (typeof orgId === "string") return orgId;
57
69
  }
58
70
  if (defaultOrgId) return defaultOrgId;
59
- if (_bootDefaultOrgId) return _bootDefaultOrgId;
60
71
  if (isStrictOrgResolution(commerceConfig)) {
61
72
  throw new OrgResolutionError(
62
73
  "Organization could not be resolved: no actor organizationId, no defaultOrgId, and no configured default organization.",
63
74
  );
64
75
  }
76
+ if (_bootDefaultOrgId) return _bootDefaultOrgId;
65
77
  warnDeprecatedOrgDefaultFallback();
66
78
  return DEFAULT_ORG_ID;
67
79
  }
@@ -0,0 +1,158 @@
1
+ import type { MiddlewareHandler } from "hono";
2
+ import { and, eq } from "drizzle-orm";
3
+ import type { CommerceConfig } from "../config/types.js";
4
+ import type { DrizzleDatabase } from "../kernel/database/drizzle-db.js";
5
+ import { invitation, member } from "./auth-schema.js";
6
+ import { canGrantRole, granterForRole, isCompositeRole } from "./role-authority.js";
7
+
8
+ /**
9
+ * Guards Better Auth's organization endpoints, which this repository mounts but
10
+ * does not govern.
11
+ *
12
+ * Red-team round 6 found two problems behind that mount.
13
+ *
14
+ * Invitation acceptance re-checked nothing: an invitation minted a member at
15
+ * the authority its inviter held up to seven days ago, so a demoted or revoked
16
+ * inviter's outstanding invitation still handed out ownership — a full
17
+ * organization takeover. Acceptance now re-reads the inviter's current
18
+ * authority before the grant is allowed through.
19
+ *
20
+ * And the plugin's own membership writers are a second role-changing surface
21
+ * running none of the rules `admin/staff.ts` enforces: no permission
22
+ * containment, no rank floor, no last-owner invariant, and they accept
23
+ * comma-composite role strings that `admin/staff.ts` refuses. They are refused
24
+ * here. `admin/staff.ts` is this repository's governed membership surface, and
25
+ * having one is worth more than making two of them agree.
26
+ */
27
+
28
+ const ORGANIZATION_PREFIX = "/api/auth/organization/";
29
+
30
+ /**
31
+ * Better Auth endpoints that write membership or define what a role may do.
32
+ *
33
+ * `invite-member` currently throws for every role instead of refusing: the
34
+ * plugin is configured with commerce permission arrays where its access-control
35
+ * model expects `Role` objects exposing `.authorize()`, so its permission check
36
+ * dies of type confusion. That is fail-closed by accident, and the accident is
37
+ * one "fix the roles wiring" commit away from being fail-open. Refusing here
38
+ * makes the closure deliberate, so repairing that wiring cannot quietly open an
39
+ * ungoverned door. `update-member-role` is not even inert today — Better Auth
40
+ * short-circuits it for organization creators, so an owner reaches it.
41
+ */
42
+ const REFUSED_ENDPOINTS = new Set([
43
+ "invite-member",
44
+ "update-member-role",
45
+ "remove-member",
46
+ "leave",
47
+ "create-role",
48
+ "update-role",
49
+ "delete-role",
50
+ ]);
51
+
52
+ function endpointName(path: string): string | null {
53
+ const normalized = path.replace(/\/+$/, "");
54
+ return normalized.startsWith(ORGANIZATION_PREFIX)
55
+ ? normalized.slice(ORGANIZATION_PREFIX.length)
56
+ : null;
57
+ }
58
+
59
+ async function readJsonBody(request: Request): Promise<Record<string, unknown>> {
60
+ try {
61
+ return (await request.clone().json()) as Record<string, unknown>;
62
+ } catch {
63
+ return {};
64
+ }
65
+ }
66
+
67
+ export function organizationGuard(
68
+ db: DrizzleDatabase,
69
+ config: CommerceConfig,
70
+ ): MiddlewareHandler {
71
+ return async (c, next) => {
72
+ if (c.req.method !== "POST") {
73
+ await next();
74
+ return;
75
+ }
76
+
77
+ const endpoint = endpointName(c.req.path);
78
+ if (endpoint === null) {
79
+ await next();
80
+ return;
81
+ }
82
+
83
+ if (REFUSED_ENDPOINTS.has(endpoint)) {
84
+ return c.json(
85
+ {
86
+ error: {
87
+ code: "FORBIDDEN",
88
+ message: `"${endpoint}" is not a governed membership surface. Use /api/admin/staff, which enforces permission containment, the rank floor, and the last-owner invariant.`,
89
+ },
90
+ },
91
+ 403,
92
+ );
93
+ }
94
+
95
+ if (endpoint !== "accept-invitation") {
96
+ await next();
97
+ return;
98
+ }
99
+
100
+ const body = await readJsonBody(c.req.raw);
101
+ const invitationId = typeof body.invitationId === "string" ? body.invitationId : null;
102
+ if (!invitationId) {
103
+ await next();
104
+ return;
105
+ }
106
+
107
+ const rows = await db
108
+ .select()
109
+ .from(invitation)
110
+ .where(eq(invitation.id, invitationId));
111
+ const pending = rows[0];
112
+ // Missing, already-answered, or expired invitations are Better Auth's own
113
+ // error surface — do not duplicate its messages here.
114
+ if (!pending || pending.status !== "pending") {
115
+ await next();
116
+ return;
117
+ }
118
+
119
+ const invitedRole = pending.role ?? "member";
120
+ const inviterRows = await db
121
+ .select({ role: member.role })
122
+ .from(member)
123
+ .where(
124
+ and(
125
+ eq(member.organizationId, pending.organizationId),
126
+ eq(member.userId, pending.inviterId),
127
+ ),
128
+ );
129
+ const inviterRole = inviterRows[0]?.role;
130
+
131
+ const stillAuthorized =
132
+ inviterRole !== undefined &&
133
+ !isCompositeRole(invitedRole) &&
134
+ canGrantRole(config, granterForRole(config, inviterRole), invitedRole);
135
+
136
+ if (stillAuthorized) {
137
+ await next();
138
+ return;
139
+ }
140
+
141
+ // The grant is dead: burn it rather than leaving it to be retried.
142
+ await db
143
+ .update(invitation)
144
+ .set({ status: "canceled" })
145
+ .where(and(eq(invitation.id, invitationId), eq(invitation.status, "pending")));
146
+
147
+ return c.json(
148
+ {
149
+ error: {
150
+ code: "FORBIDDEN",
151
+ message:
152
+ "This invitation is no longer valid: the member who sent it can no longer grant that role.",
153
+ },
154
+ },
155
+ 403,
156
+ );
157
+ };
158
+ }
@@ -1,16 +1,18 @@
1
1
  import { CommerceForbiddenError } from "../kernel/errors.js";
2
2
  import type { Actor } from "./types.js";
3
3
 
4
- export function assertPermission(actor: Actor | null, required: string): void {
5
- if (!actor) {
6
- throw new CommerceForbiddenError("Authentication required.");
7
- }
8
-
9
- if (actor.permissions.includes("*:*")) return;
4
+ export function hasPermission(actor: Actor | null, required: string): boolean {
5
+ if (!actor) return false;
6
+ if (actor.permissions.includes("*:*")) return true;
10
7
 
11
8
  const [resource] = required.split(":");
12
- if (resource && actor.permissions.includes(`${resource}:*`)) return;
13
- if (actor.permissions.includes(required)) return;
9
+ if (resource && actor.permissions.includes(`${resource}:*`)) return true;
10
+ return actor.permissions.includes(required);
11
+ }
12
+
13
+ export function assertPermission(actor: Actor | null, required: string): void {
14
+ if (hasPermission(actor, required)) return;
15
+ if (!actor) throw new CommerceForbiddenError("Authentication required.");
14
16
 
15
17
  throw new CommerceForbiddenError(
16
18
  `Permission "${required}" is required. Your role "${actor.role}" does not include this permission.`,
@@ -22,7 +24,28 @@ export function assertOwnership(actor: Actor | null, resourceOwnerId: string | n
22
24
  throw new CommerceForbiddenError("Authentication required.");
23
25
  }
24
26
  if (actor.permissions.includes("*:*")) return;
27
+ if (!actor.userId || !resourceOwnerId) {
28
+ throw new CommerceForbiddenError("You do not have access to this resource.");
29
+ }
25
30
  if (actor.userId !== resourceOwnerId) {
26
31
  throw new CommerceForbiddenError("You do not have access to this resource.");
27
32
  }
28
33
  }
34
+
35
+ /**
36
+ * The actor's user id, for code that keys a per-person resource on it.
37
+ *
38
+ * An actor without a user identity — a store resolver, an API key — has no
39
+ * owner key at all. Passing its absence through as one would make every such
40
+ * caller look like the same person, so this refuses instead of returning a
41
+ * value that reads as an identity.
42
+ */
43
+ export function requireUserId(actor: Pick<Actor, "userId"> | null): string {
44
+ if (!actor) {
45
+ throw new CommerceForbiddenError("Authentication required.");
46
+ }
47
+ if (!actor.userId) {
48
+ throw new CommerceForbiddenError("This action requires a signed-in user.");
49
+ }
50
+ return actor.userId;
51
+ }
@@ -0,0 +1,107 @@
1
+ import type { CommerceConfig } from "../config/types.js";
2
+
3
+ const BUILTIN_ROLE_RANK: Record<string, number> = {
4
+ owner: 3,
5
+ admin: 2,
6
+ };
7
+ const CUSTOM_ROLE_RANK = 1;
8
+
9
+ /**
10
+ * Who may grant which role.
11
+ *
12
+ * Every surface that mints or changes a membership answers this question, and
13
+ * they must all answer it the same way — the admin staff routes, and the guard
14
+ * in front of Better Auth's organization endpoints. Red-team round 6 found the
15
+ * takeover that follows from two surfaces disagreeing, so the arithmetic lives
16
+ * here and nowhere else.
17
+ */
18
+
19
+ /**
20
+ * Better Auth stores a member's role as a comma-separated list, so `"owner"`
21
+ * and `"owner,admin"` are both owners to it. Commerce role lookups are exact
22
+ * string matches, which is how a composite string used to read as an unknown
23
+ * custom role on one side and an owner on the other.
24
+ */
25
+ export function roleParts(role: string | null | undefined): string[] {
26
+ return (role ?? "")
27
+ .split(",")
28
+ .map((part) => part.trim())
29
+ .filter((part) => part.length > 0);
30
+ }
31
+
32
+ export function isCompositeRole(role: string | null | undefined): boolean {
33
+ return roleParts(role).length > 1;
34
+ }
35
+
36
+ /** True when any part of the role string is `owner`, matching Better Auth. */
37
+ export function isOwnerRole(role: string | null | undefined): boolean {
38
+ return roleParts(role).includes("owner");
39
+ }
40
+
41
+ export function permissionsForRole(config: CommerceConfig, role: string): string[] {
42
+ return config.auth?.roles?.[role]?.permissions ?? [];
43
+ }
44
+
45
+ export function hasPermissionFromList(
46
+ actorPermissions: readonly string[],
47
+ required: string,
48
+ ): boolean {
49
+ if (actorPermissions.includes("*:*")) return true;
50
+
51
+ const [resource] = required.split(":");
52
+ if (resource && actorPermissions.includes(`${resource}:*`)) return true;
53
+ return actorPermissions.includes(required);
54
+ }
55
+
56
+ /**
57
+ * The role's privilege rank. A composite role ranks as its strongest part, so
58
+ * `"owner,admin"` cannot slip past a rank floor by failing an exact lookup.
59
+ */
60
+ export function roleRank(config: CommerceConfig, role: string | null | undefined): number {
61
+ const ranks = roleParts(role).map((part) => {
62
+ const builtin = BUILTIN_ROLE_RANK[part];
63
+ if (builtin !== undefined) return builtin;
64
+ if (permissionsForRole(config, part).includes("*:*")) return BUILTIN_ROLE_RANK["admin"]!;
65
+ return CUSTOM_ROLE_RANK;
66
+ });
67
+ return ranks.length > 0 ? Math.max(...ranks) : CUSTOM_ROLE_RANK;
68
+ }
69
+
70
+ export interface Granter {
71
+ role: string;
72
+ permissions: readonly string[];
73
+ }
74
+
75
+ /**
76
+ * A granter may assign `targetRole` only when it already holds every permission
77
+ * that role carries (containment) and outranks or equals it (rank floor).
78
+ */
79
+ export function canGrantRole(
80
+ config: CommerceConfig,
81
+ granter: Granter | null,
82
+ targetRole: string,
83
+ ): boolean {
84
+ if (!granter) return false;
85
+ const contains = permissionsForRole(config, targetRole).every((permission) =>
86
+ hasPermissionFromList(granter.permissions, permission),
87
+ );
88
+ return contains && roleRank(config, granter.role) >= roleRank(config, targetRole);
89
+ }
90
+
91
+ /**
92
+ * The granter a stored membership row represents. Permissions come from the
93
+ * role's current definition, which is the whole point of re-checking authority
94
+ * later: the row is re-read, not the authority the actor had when they acted.
95
+ */
96
+ export function granterForRole(config: CommerceConfig, role: string): Granter {
97
+ return { role, permissions: permissionsForRole(config, role) };
98
+ }
99
+
100
+ export function validRoles(config: CommerceConfig): Set<string> {
101
+ return new Set([
102
+ ...Object.keys(config.auth?.roles ?? {}),
103
+ "owner",
104
+ "admin",
105
+ "member",
106
+ ]);
107
+ }
package/src/auth/setup.ts CHANGED
@@ -6,6 +6,7 @@ import { organization, twoFactor, phoneNumber, jwt, bearer } from "better-auth/p
6
6
  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
+ import { AUTH_COOKIE_PREFIX } from "./actor.js";
9
10
 
10
11
  type BetterAuthDbProvider = "pg";
11
12
 
@@ -65,6 +66,23 @@ export function createAuth(
65
66
  | ReturnType<typeof jwt>
66
67
  | ReturnType<typeof bearer>
67
68
  > = [
69
+ // These are commerce permission arrays, not Better Auth access-control
70
+ // `Role` objects exposing `.authorize()`. Its organization plugin therefore
71
+ // cannot evaluate a permission with them, and every check reaching its
72
+ // authorize loop throws instead of deciding — fail-closed, but by type
73
+ // confusion rather than by intent.
74
+ //
75
+ // That is deliberate and stays. This repository does not use Better Auth's
76
+ // access-control model: `admin/staff.ts` is the governed membership
77
+ // surface, and `auth/role-authority.ts` holds the arithmetic. Handing the
78
+ // plugin real `Role` objects would stand up a second permission model to
79
+ // keep in agreement with the first, which is how the two layers disagreed
80
+ // about composite roles and owner counting in the first place.
81
+ //
82
+ // What must not be relied on is the throw. `auth/organization-guard.ts`
83
+ // refuses the plugin's membership writers outright, so repairing this
84
+ // wiring cannot turn an accidental closure into an open door. Pin that
85
+ // guard's test before changing anything here.
68
86
  organization({
69
87
  roles: (config.auth?.roles ?? {}) as unknown as Record<string, Role | undefined>,
70
88
  }),
@@ -161,7 +179,7 @@ export function createAuth(
161
179
  },
162
180
  },
163
181
  advanced: {
164
- cookiePrefix: "uc",
182
+ cookiePrefix: AUTH_COOKIE_PREFIX,
165
183
  useSecureCookies: process.env.NODE_ENV === "production",
166
184
  // VAPT r2 (pi) finding: SameSite was never set anywhere. Without it,
167
185
  // browsers default to Lax for new cookies, but the explicit setting
@@ -7,8 +7,14 @@ export function setBootStrictOrgResolution(strict: boolean): void {
7
7
  }
8
8
 
9
9
  export function isStrictOrgResolution(config?: CommerceConfig | null): boolean {
10
+ // Fail closed by default. Without an actor there is no tenant, and silently
11
+ // resolving to a default organization served one merchant's data to
12
+ // unauthenticated callers on every unguarded or allowlisted read path.
13
+ // Single-tenant deployments that relied on that fallback opt out explicitly.
14
+ if (config?.auth?.strictOrgResolution === false) return false;
15
+ if (process.env.STRICT_ORG_RESOLUTION === "false") return false;
10
16
  if (config?.auth?.strictOrgResolution === true) return true;
11
17
  if (process.env.STRICT_ORG_RESOLUTION === "true") return true;
12
18
  if ((config === undefined || config === null) && _bootStrictOrgResolution) return true;
13
- return false;
19
+ return true;
14
20
  }