@porulle/core 0.11.0 → 0.13.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.
- package/dist/auth/auth-schema-guard.d.ts +2444 -0
- package/dist/auth/auth-schema-guard.d.ts.map +1 -0
- package/dist/auth/auth-schema-guard.js +113 -0
- package/dist/auth/auth-schema.d.ts +228 -0
- package/dist/auth/auth-schema.d.ts.map +1 -1
- package/dist/auth/auth-schema.js +24 -2
- package/dist/auth/middleware.d.ts +1 -0
- package/dist/auth/middleware.d.ts.map +1 -1
- package/dist/auth/middleware.js +13 -5
- package/dist/auth/org.d.ts +8 -2
- package/dist/auth/org.d.ts.map +1 -1
- package/dist/auth/org.js +12 -4
- package/dist/auth/organization-guard.d.ts +5 -0
- package/dist/auth/organization-guard.d.ts.map +1 -0
- package/dist/auth/organization-guard.js +124 -0
- package/dist/auth/permissions.d.ts +10 -0
- package/dist/auth/permissions.d.ts.map +1 -1
- package/dist/auth/permissions.js +31 -7
- package/dist/auth/role-authority.d.ts +44 -0
- package/dist/auth/role-authority.d.ts.map +1 -0
- package/dist/auth/role-authority.js +85 -0
- package/dist/auth/setup.d.ts.map +1 -1
- package/dist/auth/setup.js +17 -0
- package/dist/auth/strict-org-resolution.d.ts.map +1 -1
- package/dist/auth/strict-org-resolution.js +9 -1
- package/dist/auth/types.d.ts +1 -1
- package/dist/auth/types.d.ts.map +1 -1
- package/dist/config/defaults.d.ts.map +1 -1
- package/dist/config/defaults.js +2 -0
- package/dist/config/types.d.ts +30 -4
- package/dist/config/types.d.ts.map +1 -1
- package/dist/hooks/checkout.d.ts.map +1 -1
- package/dist/hooks/checkout.js +10 -9
- package/dist/index.d.ts +6 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -3
- package/dist/interfaces/rest/audit-middleware.d.ts.map +1 -1
- package/dist/interfaces/rest/audit-middleware.js +2 -0
- package/dist/interfaces/rest/customer-portal.d.ts.map +1 -1
- package/dist/interfaces/rest/customer-portal.js +9 -8
- package/dist/interfaces/rest/route-coverage.d.ts +14 -0
- package/dist/interfaces/rest/route-coverage.d.ts.map +1 -0
- package/dist/interfaces/rest/route-coverage.js +81 -0
- package/dist/interfaces/rest/router.d.ts +7 -2
- package/dist/interfaces/rest/router.d.ts.map +1 -1
- package/dist/interfaces/rest/router.js +8 -4
- package/dist/interfaces/rest/routes/admin/compensation-failures.d.ts.map +1 -1
- package/dist/interfaces/rest/routes/admin/compensation-failures.js +7 -5
- package/dist/interfaces/rest/routes/admin/custom-field-proposals.js +2 -2
- package/dist/interfaces/rest/routes/admin/permissions.d.ts +1 -1
- package/dist/interfaces/rest/routes/admin/permissions.d.ts.map +1 -1
- package/dist/interfaces/rest/routes/admin/permissions.js +2 -0
- package/dist/interfaces/rest/routes/admin/staff.d.ts.map +1 -1
- package/dist/interfaces/rest/routes/admin/staff.js +126 -66
- package/dist/interfaces/rest/routes/admin-jobs.js +3 -3
- package/dist/interfaces/rest/routes/audit.js +3 -3
- package/dist/interfaces/rest/routes/carts.d.ts.map +1 -1
- package/dist/interfaces/rest/routes/carts.js +13 -10
- package/dist/interfaces/rest/routes/catalog.d.ts.map +1 -1
- package/dist/interfaces/rest/routes/catalog.js +10 -2
- package/dist/interfaces/rest/routes/checkout.d.ts.map +1 -1
- package/dist/interfaces/rest/routes/checkout.js +30 -35
- package/dist/interfaces/rest/routes/documents.d.ts.map +1 -1
- package/dist/interfaces/rest/routes/documents.js +10 -5
- package/dist/interfaces/rest/routes/inventory.d.ts.map +1 -1
- package/dist/interfaces/rest/routes/inventory.js +2 -0
- package/dist/interfaces/rest/routes/media.d.ts.map +1 -1
- package/dist/interfaces/rest/routes/media.js +7 -3
- package/dist/interfaces/rest/routes/orders.d.ts.map +1 -1
- package/dist/interfaces/rest/routes/orders.js +26 -8
- package/dist/interfaces/rest/routes/pricing.js +2 -2
- package/dist/interfaces/rest/routes/promotions.d.ts.map +1 -1
- package/dist/interfaces/rest/routes/promotions.js +13 -10
- package/dist/interfaces/rest/routes/search.d.ts.map +1 -1
- package/dist/interfaces/rest/routes/search.js +2 -1
- package/dist/interfaces/rest/schemas/carts.d.ts +7 -0
- package/dist/interfaces/rest/schemas/carts.d.ts.map +1 -1
- package/dist/interfaces/rest/schemas/customer-portal.d.ts +35 -0
- package/dist/interfaces/rest/schemas/customer-portal.d.ts.map +1 -1
- package/dist/interfaces/rest/schemas/orders.d.ts +204 -0
- package/dist/interfaces/rest/schemas/orders.d.ts.map +1 -1
- package/dist/interfaces/rest/schemas/responses.d.ts +71 -0
- package/dist/interfaces/rest/schemas/responses.d.ts.map +1 -1
- package/dist/interfaces/rest/utils.d.ts +15 -0
- package/dist/interfaces/rest/utils.d.ts.map +1 -1
- package/dist/interfaces/rest/utils.js +45 -2
- package/dist/kernel/compensation/executor.js +2 -2
- package/dist/kernel/database/tx-context.d.ts +8 -0
- package/dist/kernel/database/tx-context.d.ts.map +1 -1
- package/dist/kernel/database/tx-context.js +13 -0
- package/dist/kernel/hooks/create-context.d.ts +2 -0
- package/dist/kernel/hooks/create-context.d.ts.map +1 -1
- package/dist/kernel/hooks/create-context.js +1 -0
- package/dist/kernel/hooks/types.d.ts +2 -0
- package/dist/kernel/hooks/types.d.ts.map +1 -1
- package/dist/kernel/plugin/manifest.d.ts.map +1 -1
- package/dist/kernel/plugin/manifest.js +9 -5
- package/dist/modules/analytics/service.js +2 -2
- package/dist/modules/audit/service.js +3 -3
- package/dist/modules/cart/repository/index.d.ts +13 -7
- package/dist/modules/cart/repository/index.d.ts.map +1 -1
- package/dist/modules/cart/repository/index.js +16 -10
- package/dist/modules/cart/schema.d.ts +17 -0
- package/dist/modules/cart/schema.d.ts.map +1 -1
- package/dist/modules/cart/schema.js +4 -0
- package/dist/modules/cart/service.d.ts +22 -9
- package/dist/modules/cart/service.d.ts.map +1 -1
- package/dist/modules/cart/service.js +57 -33
- package/dist/modules/catalog/brand-service.js +7 -7
- package/dist/modules/catalog/category-service.js +7 -7
- package/dist/modules/catalog/entity-service.d.ts +14 -7
- package/dist/modules/catalog/entity-service.d.ts.map +1 -1
- package/dist/modules/catalog/entity-service.js +109 -31
- package/dist/modules/catalog/read-policy.d.ts +9 -0
- package/dist/modules/catalog/read-policy.d.ts.map +1 -0
- package/dist/modules/catalog/read-policy.js +8 -0
- package/dist/modules/catalog/repository/index.d.ts +3 -2
- package/dist/modules/catalog/repository/index.d.ts.map +1 -1
- package/dist/modules/catalog/repository/index.js +14 -4
- package/dist/modules/catalog/service.d.ts +15 -13
- package/dist/modules/catalog/service.d.ts.map +1 -1
- package/dist/modules/catalog/service.js +25 -18
- package/dist/modules/customers/module.d.ts.map +1 -1
- package/dist/modules/customers/module.js +1 -0
- package/dist/modules/customers/service.d.ts +2 -0
- package/dist/modules/customers/service.d.ts.map +1 -1
- package/dist/modules/customers/service.js +21 -20
- package/dist/modules/documents/module.d.ts.map +1 -1
- package/dist/modules/documents/module.js +1 -0
- package/dist/modules/documents/service.d.ts +18 -4
- package/dist/modules/documents/service.d.ts.map +1 -1
- package/dist/modules/documents/service.js +54 -16
- package/dist/modules/fulfillment/module.d.ts.map +1 -1
- package/dist/modules/fulfillment/module.js +1 -0
- package/dist/modules/fulfillment/service.d.ts +2 -0
- package/dist/modules/fulfillment/service.d.ts.map +1 -1
- package/dist/modules/fulfillment/service.js +15 -6
- package/dist/modules/inventory/service.d.ts.map +1 -1
- package/dist/modules/inventory/service.js +15 -14
- package/dist/modules/media/service.js +6 -6
- package/dist/modules/orders/guest-access.d.ts +27 -0
- package/dist/modules/orders/guest-access.d.ts.map +1 -0
- package/dist/modules/orders/guest-access.js +46 -0
- package/dist/modules/orders/module.d.ts.map +1 -1
- package/dist/modules/orders/module.js +1 -0
- package/dist/modules/orders/quote.d.ts.map +1 -1
- package/dist/modules/orders/quote.js +1 -0
- package/dist/modules/orders/repository/index.d.ts +1 -1
- package/dist/modules/orders/repository/index.d.ts.map +1 -1
- package/dist/modules/orders/repository/index.js +2 -2
- package/dist/modules/orders/schema.d.ts +17 -0
- package/dist/modules/orders/schema.d.ts.map +1 -1
- package/dist/modules/orders/schema.js +5 -3
- package/dist/modules/orders/service.d.ts +16 -7
- package/dist/modules/orders/service.d.ts.map +1 -1
- package/dist/modules/orders/service.js +184 -55
- package/dist/modules/pricing/module.d.ts.map +1 -1
- package/dist/modules/pricing/module.js +1 -0
- package/dist/modules/pricing/service.d.ts +2 -0
- package/dist/modules/pricing/service.d.ts.map +1 -1
- package/dist/modules/pricing/service.js +12 -11
- package/dist/modules/promotions/module.d.ts.map +1 -1
- package/dist/modules/promotions/module.js +1 -0
- package/dist/modules/promotions/service.d.ts +3 -0
- package/dist/modules/promotions/service.d.ts.map +1 -1
- package/dist/modules/promotions/service.js +19 -11
- package/dist/modules/search/adapter.d.ts +6 -0
- package/dist/modules/search/adapter.d.ts.map +1 -1
- package/dist/modules/search/module.d.ts.map +1 -1
- package/dist/modules/search/module.js +1 -0
- package/dist/modules/search/service.d.ts +1 -0
- package/dist/modules/search/service.d.ts.map +1 -1
- package/dist/modules/search/service.js +57 -6
- package/dist/modules/settings/module.d.ts.map +1 -1
- package/dist/modules/settings/module.js +1 -0
- package/dist/modules/settings/service.d.ts +3 -0
- package/dist/modules/settings/service.d.ts.map +1 -1
- package/dist/modules/settings/service.js +6 -4
- package/dist/modules/shipping/service.js +9 -9
- package/dist/modules/tax/module.d.ts.map +1 -1
- package/dist/modules/tax/module.js +1 -0
- package/dist/modules/tax/service.d.ts +3 -0
- package/dist/modules/tax/service.d.ts.map +1 -1
- package/dist/modules/tax/service.js +11 -9
- package/dist/modules/webhooks/hook.js +2 -2
- package/dist/modules/webhooks/module.d.ts.map +1 -1
- package/dist/modules/webhooks/module.js +1 -0
- package/dist/modules/webhooks/service.d.ts +2 -0
- package/dist/modules/webhooks/service.d.ts.map +1 -1
- package/dist/modules/webhooks/service.js +4 -4
- package/dist/runtime/server.d.ts.map +1 -1
- package/dist/runtime/server.js +10 -2
- package/dist/test-utils/create-pglite-adapter.d.ts.map +1 -1
- package/dist/test-utils/create-pglite-adapter.js +35 -13
- package/dist/test-utils/create-test-config.d.ts.map +1 -1
- package/dist/test-utils/create-test-config.js +4 -0
- package/dist/test-utils/rest-api-test-utils.d.ts.map +1 -1
- package/dist/test-utils/rest-api-test-utils.js +2 -0
- package/dist/test-utils/test-actors.js +2 -2
- package/dist/utils/id.d.ts +2 -0
- package/dist/utils/id.d.ts.map +1 -1
- package/dist/utils/id.js +11 -0
- package/package.json +6 -5
- package/src/auth/auth-schema-guard.ts +143 -0
- package/src/auth/auth-schema.ts +50 -19
- package/src/auth/middleware.ts +17 -5
- package/src/auth/org.ts +15 -3
- package/src/auth/organization-guard.ts +158 -0
- package/src/auth/permissions.ts +31 -8
- package/src/auth/role-authority.ts +107 -0
- package/src/auth/setup.ts +17 -0
- package/src/auth/strict-org-resolution.ts +7 -1
- package/src/auth/types.ts +1 -1
- package/src/config/defaults.ts +2 -0
- package/src/config/types.ts +29 -4
- package/src/hooks/checkout.ts +19 -12
- package/src/index.ts +16 -6
- package/src/interfaces/rest/audit-middleware.ts +2 -0
- package/src/interfaces/rest/customer-portal.ts +31 -24
- package/src/interfaces/rest/route-coverage.ts +103 -0
- package/src/interfaces/rest/router.ts +16 -10
- package/src/interfaces/rest/routes/admin/compensation-failures.ts +8 -5
- package/src/interfaces/rest/routes/admin/custom-field-proposals.ts +2 -2
- package/src/interfaces/rest/routes/admin/permissions.ts +3 -1
- package/src/interfaces/rest/routes/admin/staff.ts +161 -72
- package/src/interfaces/rest/routes/admin-jobs.ts +3 -3
- package/src/interfaces/rest/routes/audit.ts +3 -3
- package/src/interfaces/rest/routes/carts.ts +17 -7
- package/src/interfaces/rest/routes/catalog.ts +11 -0
- package/src/interfaces/rest/routes/checkout.ts +41 -38
- package/src/interfaces/rest/routes/documents.ts +15 -1
- package/src/interfaces/rest/routes/inventory.ts +2 -0
- package/src/interfaces/rest/routes/media.ts +8 -3
- package/src/interfaces/rest/routes/orders.ts +27 -8
- package/src/interfaces/rest/routes/pricing.ts +2 -2
- package/src/interfaces/rest/routes/promotions.ts +12 -9
- package/src/interfaces/rest/routes/search.ts +3 -1
- package/src/interfaces/rest/utils.ts +61 -2
- package/src/kernel/compensation/executor.ts +2 -2
- package/src/kernel/database/tx-context.ts +25 -0
- package/src/kernel/hooks/create-context.ts +3 -0
- package/src/kernel/hooks/types.ts +2 -0
- package/src/kernel/plugin/manifest.ts +9 -5
- package/src/modules/analytics/service.ts +2 -2
- package/src/modules/audit/service.ts +3 -3
- package/src/modules/cart/repository/index.ts +22 -8
- package/src/modules/cart/schema.ts +4 -0
- package/src/modules/cart/service.ts +62 -27
- package/src/modules/catalog/brand-service.ts +7 -7
- package/src/modules/catalog/category-service.ts +7 -7
- package/src/modules/catalog/entity-service.ts +117 -35
- package/src/modules/catalog/read-policy.ts +16 -0
- package/src/modules/catalog/repository/index.ts +19 -3
- package/src/modules/catalog/service.ts +46 -31
- package/src/modules/customers/module.ts +2 -0
- package/src/modules/customers/service.ts +23 -22
- package/src/modules/documents/module.ts +2 -0
- package/src/modules/documents/service.ts +74 -11
- package/src/modules/fulfillment/module.ts +2 -0
- package/src/modules/fulfillment/service.ts +17 -6
- package/src/modules/inventory/service.ts +15 -24
- package/src/modules/media/service.ts +6 -6
- package/src/modules/orders/guest-access.ts +55 -0
- package/src/modules/orders/module.ts +1 -0
- package/src/modules/orders/quote.ts +1 -0
- package/src/modules/orders/repository/index.ts +2 -0
- package/src/modules/orders/schema.ts +5 -3
- package/src/modules/orders/service.ts +302 -52
- package/src/modules/pricing/module.ts +2 -0
- package/src/modules/pricing/service.ts +14 -16
- package/src/modules/promotions/module.ts +2 -0
- package/src/modules/promotions/service.ts +25 -16
- package/src/modules/search/adapter.ts +6 -0
- package/src/modules/search/module.ts +1 -0
- package/src/modules/search/service.ts +55 -6
- package/src/modules/settings/module.ts +2 -0
- package/src/modules/settings/service.ts +8 -4
- package/src/modules/shipping/service.ts +9 -9
- package/src/modules/tax/module.ts +1 -0
- package/src/modules/tax/service.ts +13 -9
- package/src/modules/webhooks/hook.ts +2 -2
- package/src/modules/webhooks/module.ts +2 -0
- package/src/modules/webhooks/service.ts +6 -4
- package/src/runtime/server.ts +16 -2
- package/src/test-utils/create-pglite-adapter.ts +34 -12
- package/src/test-utils/create-test-config.ts +10 -6
- package/src/test-utils/rest-api-test-utils.ts +2 -0
- package/src/test-utils/test-actors.ts +2 -2
- package/src/utils/id.ts +20 -0
|
@@ -4,7 +4,7 @@ import type { Kernel } from "../../../../runtime/kernel.js";
|
|
|
4
4
|
import type { CommerceConfig } from "../../../../config/types.js";
|
|
5
5
|
import type { DrizzleDatabase } from "../../../../kernel/database/drizzle-db.js";
|
|
6
6
|
import { member, user, invitation } from "../../../../auth/auth-schema.js";
|
|
7
|
-
import {
|
|
7
|
+
import { resolveOrgIdForCommerce } from "../../../../auth/org.js";
|
|
8
8
|
import { makeId } from "../../../../utils/id.js";
|
|
9
9
|
import {
|
|
10
10
|
listStaffRoute,
|
|
@@ -15,6 +15,15 @@ import {
|
|
|
15
15
|
updateStaffRoleRoute,
|
|
16
16
|
revokeStaffRoute,
|
|
17
17
|
} from "../../schemas/admin-staff.js";
|
|
18
|
+
import type { Actor } from "../../../../auth/types.js";
|
|
19
|
+
import {
|
|
20
|
+
canGrantRole,
|
|
21
|
+
granterForRole,
|
|
22
|
+
isCompositeRole,
|
|
23
|
+
isOwnerRole,
|
|
24
|
+
roleRank,
|
|
25
|
+
validRoles,
|
|
26
|
+
} from "../../../../auth/role-authority.js";
|
|
18
27
|
import { type AppEnv, requirePerm } from "../../utils.js";
|
|
19
28
|
|
|
20
29
|
/**
|
|
@@ -34,39 +43,28 @@ export function adminStaffRoutes(kernel: Kernel) {
|
|
|
34
43
|
router.use("/staff/invitations", requirePerm("staff:manage"));
|
|
35
44
|
router.use("/staff/roles", requirePerm("staff:manage"));
|
|
36
45
|
|
|
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
46
|
function invalidRole(c: { json: (d: unknown, s: number) => unknown }, role: string) {
|
|
47
47
|
return c.json(
|
|
48
48
|
{
|
|
49
49
|
error: {
|
|
50
50
|
code: "VALIDATION_FAILED",
|
|
51
|
-
message: `Unknown role "${role}". Valid roles: ${[...validRoles()].join(", ")}.`,
|
|
51
|
+
message: `Unknown role "${role}". Valid roles: ${[...validRoles(config)].join(", ")}.`,
|
|
52
52
|
},
|
|
53
53
|
},
|
|
54
54
|
422,
|
|
55
55
|
);
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
-
|
|
59
|
-
|
|
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;
|
|
58
|
+
function rankOf(role: string | null | undefined): number {
|
|
59
|
+
return roleRank(config, role);
|
|
66
60
|
}
|
|
67
61
|
|
|
68
|
-
function
|
|
69
|
-
return
|
|
62
|
+
function actorCanGrantRole(actor: Actor | null, targetRole: string): boolean {
|
|
63
|
+
return canGrantRole(
|
|
64
|
+
config,
|
|
65
|
+
actor ? { role: actor.role, permissions: actor.permissions } : null,
|
|
66
|
+
targetRole,
|
|
67
|
+
);
|
|
70
68
|
}
|
|
71
69
|
|
|
72
70
|
function insufficientPrivilege(
|
|
@@ -84,16 +82,81 @@ export function adminStaffRoutes(kernel: Kernel) {
|
|
|
84
82
|
);
|
|
85
83
|
}
|
|
86
84
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
85
|
+
/**
|
|
86
|
+
* Runs a membership mutation with the organization's member rows locked, so
|
|
87
|
+
* the last-owner check and the write it guards cannot be interleaved.
|
|
88
|
+
*
|
|
89
|
+
* Counting owners and then mutating as two statements let two owners revoke
|
|
90
|
+
* each other concurrently: both read `count = 2`, both proceed, and the
|
|
91
|
+
* organization is left with none. The lock makes the second transaction
|
|
92
|
+
* re-read after the first commits, so it sees the count it must respect.
|
|
93
|
+
*
|
|
94
|
+
* The whole member set is locked, not just the owner rows, because an owner
|
|
95
|
+
* is any member whose role string contains `owner` — a predicate SQL cannot
|
|
96
|
+
* express as cheaply as the JS filter below. Membership writes are rare
|
|
97
|
+
* administrative actions, so serialising them per organization is free.
|
|
98
|
+
* `ORDER BY id` fixes the lock order and keeps two concurrent revocations
|
|
99
|
+
* from deadlocking each other.
|
|
100
|
+
*/
|
|
101
|
+
async function withMembershipLock<T>(
|
|
102
|
+
orgId: string,
|
|
103
|
+
run: (tx: DrizzleDatabase, ownerIds: string[]) => Promise<T>,
|
|
104
|
+
): Promise<T> {
|
|
105
|
+
return kernel.database.transaction(async (raw) => {
|
|
106
|
+
const tx = raw as DrizzleDatabase;
|
|
107
|
+
const locked = await tx
|
|
108
|
+
.select({ id: member.id, role: member.role })
|
|
109
|
+
.from(member)
|
|
110
|
+
.where(eq(member.organizationId, orgId))
|
|
111
|
+
.orderBy(member.id)
|
|
112
|
+
.for("update");
|
|
113
|
+
const ownerIds = locked
|
|
114
|
+
.filter((row) => isOwnerRole(row.role))
|
|
115
|
+
.map((row) => row.id);
|
|
116
|
+
return run(tx, ownerIds);
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Cancels the member's pending invitations that their role can no longer
|
|
122
|
+
* grant. Acceptance re-checks the inviter's authority too, so this is not
|
|
123
|
+
* what closes the takeover — it keeps the invitation list honest and stops a
|
|
124
|
+
* dead grant sitting there for the rest of its seven-day life.
|
|
125
|
+
*
|
|
126
|
+
* `newRole` is null when the membership is being revoked outright.
|
|
127
|
+
*/
|
|
128
|
+
async function cancelUngrantableInvitations(
|
|
129
|
+
orgId: string,
|
|
130
|
+
inviterUserId: string,
|
|
131
|
+
newRole: string | null,
|
|
132
|
+
): Promise<void> {
|
|
133
|
+
const pending = await db
|
|
134
|
+
.select({ id: invitation.id, role: invitation.role })
|
|
135
|
+
.from(invitation)
|
|
136
|
+
.where(
|
|
137
|
+
and(
|
|
138
|
+
eq(invitation.organizationId, orgId),
|
|
139
|
+
eq(invitation.inviterId, inviterUserId),
|
|
140
|
+
eq(invitation.status, "pending"),
|
|
141
|
+
),
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
const dead = pending.filter(
|
|
145
|
+
(row) =>
|
|
146
|
+
newRole === null ||
|
|
147
|
+
!canGrantRole(config, granterForRole(config, newRole), row.role ?? "member"),
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
for (const row of dead) {
|
|
151
|
+
await db
|
|
152
|
+
.update(invitation)
|
|
153
|
+
.set({ status: "canceled" })
|
|
154
|
+
.where(eq(invitation.id, row.id));
|
|
155
|
+
}
|
|
93
156
|
}
|
|
94
157
|
|
|
95
158
|
router.openapi(listStaffRoute, async (c) => {
|
|
96
|
-
const orgId =
|
|
159
|
+
const orgId = resolveOrgIdForCommerce(c.get("actor"), config);
|
|
97
160
|
const rows = await db
|
|
98
161
|
.select({
|
|
99
162
|
id: member.id,
|
|
@@ -113,10 +176,10 @@ export function adminStaffRoutes(kernel: Kernel) {
|
|
|
113
176
|
router.openapi(createStaffRoute, async (c) => {
|
|
114
177
|
const body = c.req.valid("json") as { userId: string; role: string };
|
|
115
178
|
const actor = c.get("actor");
|
|
116
|
-
const orgId =
|
|
179
|
+
const orgId = resolveOrgIdForCommerce(actor, config);
|
|
117
180
|
|
|
118
|
-
if (!validRoles().has(body.role)) return invalidRole(c, body.role);
|
|
119
|
-
if (!
|
|
181
|
+
if (isCompositeRole(body.role) || !validRoles(config).has(body.role)) return invalidRole(c, body.role);
|
|
182
|
+
if (!actorCanGrantRole(actor, body.role)) return insufficientPrivilege(c, body.role);
|
|
120
183
|
|
|
121
184
|
const users = await db.select().from(user).where(eq(user.id, body.userId));
|
|
122
185
|
if (users.length === 0) {
|
|
@@ -151,10 +214,10 @@ export function adminStaffRoutes(kernel: Kernel) {
|
|
|
151
214
|
router.openapi(inviteStaffRoute, async (c) => {
|
|
152
215
|
const body = c.req.valid("json") as { email: string; role: string };
|
|
153
216
|
const actor = c.get("actor");
|
|
154
|
-
const orgId =
|
|
155
|
-
|
|
156
|
-
if (!validRoles().has(body.role)) return invalidRole(c, body.role);
|
|
217
|
+
const orgId = resolveOrgIdForCommerce(actor, config);
|
|
157
218
|
|
|
219
|
+
if (isCompositeRole(body.role) || !validRoles(config).has(body.role)) return invalidRole(c, body.role);
|
|
220
|
+
if (!actorCanGrantRole(actor, body.role)) return insufficientPrivilege(c, body.role);
|
|
158
221
|
const rows = await db
|
|
159
222
|
.insert(invitation)
|
|
160
223
|
.values({
|
|
@@ -164,14 +227,14 @@ export function adminStaffRoutes(kernel: Kernel) {
|
|
|
164
227
|
role: body.role,
|
|
165
228
|
status: "pending",
|
|
166
229
|
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
|
|
167
|
-
inviterId: actor
|
|
230
|
+
inviterId: actor?.userId ?? "system",
|
|
168
231
|
})
|
|
169
232
|
.returning();
|
|
170
233
|
return c.json({ data: rows[0] }, 201);
|
|
171
234
|
});
|
|
172
235
|
|
|
173
236
|
router.openapi(listStaffInvitationsRoute, async (c) => {
|
|
174
|
-
const orgId =
|
|
237
|
+
const orgId = resolveOrgIdForCommerce(c.get("actor"), config);
|
|
175
238
|
const rows = await db
|
|
176
239
|
.select()
|
|
177
240
|
.from(invitation)
|
|
@@ -192,67 +255,93 @@ export function adminStaffRoutes(kernel: Kernel) {
|
|
|
192
255
|
router.openapi(updateStaffRoleRoute, async (c) => {
|
|
193
256
|
const body = c.req.valid("json") as { role: string };
|
|
194
257
|
const actor = c.get("actor");
|
|
195
|
-
const orgId =
|
|
258
|
+
const orgId = resolveOrgIdForCommerce(actor, config);
|
|
196
259
|
const id = c.req.param("id");
|
|
197
260
|
|
|
198
|
-
if (!validRoles().has(body.role)) return invalidRole(c, body.role);
|
|
199
|
-
if (!
|
|
200
|
-
|
|
201
|
-
const
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
261
|
+
if (isCompositeRole(body.role) || !validRoles(config).has(body.role)) return invalidRole(c, body.role);
|
|
262
|
+
if (!actorCanGrantRole(actor, body.role)) return insufficientPrivilege(c, body.role);
|
|
263
|
+
|
|
264
|
+
const outcome = await withMembershipLock(orgId, async (tx, ownerIds) => {
|
|
265
|
+
const rows = await tx
|
|
266
|
+
.select()
|
|
267
|
+
.from(member)
|
|
268
|
+
.where(and(eq(member.organizationId, orgId), eq(member.id, id)));
|
|
269
|
+
const target = rows[0];
|
|
270
|
+
if (!target) return { kind: "not-found" } as const;
|
|
271
|
+
|
|
272
|
+
// SEC-18/R-02: the actor must also outrank (or equal) the target's CURRENT
|
|
273
|
+
// role, else an admin could demote an owner they do not outrank.
|
|
274
|
+
if (rankOf(actor!.role) < rankOf(target.role)) {
|
|
275
|
+
return { kind: "outranked", role: target.role } as const;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
if (isOwnerRole(target.role) && !isOwnerRole(body.role) && ownerIds.length <= 1) {
|
|
279
|
+
return { kind: "last-owner" } as const;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const updated = await tx
|
|
283
|
+
.update(member)
|
|
284
|
+
.set({ role: body.role })
|
|
285
|
+
.where(eq(member.id, id))
|
|
286
|
+
.returning();
|
|
287
|
+
return { kind: "updated", target, row: updated[0] } as const;
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
if (outcome.kind === "not-found") {
|
|
207
291
|
return c.json({ error: { code: "NOT_FOUND", message: "Staff member not found." } }, 404);
|
|
208
292
|
}
|
|
209
|
-
|
|
210
|
-
|
|
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) {
|
|
293
|
+
if (outcome.kind === "outranked") return insufficientPrivilege(c, outcome.role);
|
|
294
|
+
if (outcome.kind === "last-owner") {
|
|
215
295
|
return c.json(
|
|
216
296
|
{ error: { code: "VALIDATION_FAILED", message: "Cannot demote the organization's last owner." } },
|
|
217
297
|
422,
|
|
218
298
|
);
|
|
219
299
|
}
|
|
220
300
|
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
.set({ role: body.role })
|
|
224
|
-
.where(eq(member.id, id))
|
|
225
|
-
.returning();
|
|
226
|
-
return c.json({ data: updated[0] });
|
|
301
|
+
await cancelUngrantableInvitations(orgId, outcome.target.userId, body.role);
|
|
302
|
+
return c.json({ data: outcome.row });
|
|
227
303
|
});
|
|
228
304
|
|
|
229
305
|
// @ts-expect-error -- openapi handler union return type
|
|
230
306
|
router.openapi(revokeStaffRoute, async (c) => {
|
|
231
307
|
const actor = c.get("actor");
|
|
232
|
-
const orgId =
|
|
308
|
+
const orgId = resolveOrgIdForCommerce(actor, config);
|
|
233
309
|
const id = c.req.param("id");
|
|
234
310
|
|
|
235
|
-
const
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
311
|
+
const outcome = await withMembershipLock(orgId, async (tx, ownerIds) => {
|
|
312
|
+
const rows = await tx
|
|
313
|
+
.select()
|
|
314
|
+
.from(member)
|
|
315
|
+
.where(and(eq(member.organizationId, orgId), eq(member.id, id)));
|
|
316
|
+
const target = rows[0];
|
|
317
|
+
if (!target) return { kind: "not-found" } as const;
|
|
318
|
+
|
|
319
|
+
// SEC-18/R-02: the actor must outrank (or equal) the target's current role
|
|
320
|
+
// to revoke it — an admin cannot revoke an owner.
|
|
321
|
+
if (rankOf(actor!.role) < rankOf(target.role)) {
|
|
322
|
+
return { kind: "outranked", role: target.role } as const;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
if (isOwnerRole(target.role) && ownerIds.length <= 1) {
|
|
326
|
+
return { kind: "last-owner" } as const;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
await tx.delete(member).where(eq(member.id, id));
|
|
330
|
+
return { kind: "deleted", target } as const;
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
if (outcome.kind === "not-found") {
|
|
241
334
|
return c.json({ error: { code: "NOT_FOUND", message: "Staff member not found." } }, 404);
|
|
242
335
|
}
|
|
243
|
-
|
|
244
|
-
|
|
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) {
|
|
336
|
+
if (outcome.kind === "outranked") return insufficientPrivilege(c, outcome.role);
|
|
337
|
+
if (outcome.kind === "last-owner") {
|
|
249
338
|
return c.json(
|
|
250
339
|
{ error: { code: "VALIDATION_FAILED", message: "Cannot revoke the organization's last owner." } },
|
|
251
340
|
422,
|
|
252
341
|
);
|
|
253
342
|
}
|
|
254
343
|
|
|
255
|
-
await
|
|
344
|
+
await cancelUngrantableInvitations(orgId, outcome.target.userId, null);
|
|
256
345
|
return c.json({ data: { deleted: true } });
|
|
257
346
|
});
|
|
258
347
|
|
|
@@ -5,7 +5,7 @@ import type { Kernel } from "../../../runtime/kernel.js";
|
|
|
5
5
|
import { commerceJobs } from "../../../kernel/jobs/schema.js";
|
|
6
6
|
import { listFailedJobsRoute, retryJobRoute } from "../schemas/admin-jobs.js";
|
|
7
7
|
import { type AppEnv, requirePerm } from "../utils.js";
|
|
8
|
-
import {
|
|
8
|
+
import { resolveOrgIdForCommerce } from "../../../auth/org.js";
|
|
9
9
|
|
|
10
10
|
type Db = PgDatabase<PgQueryResultHKT, Record<string, unknown>>;
|
|
11
11
|
|
|
@@ -17,7 +17,7 @@ export function adminJobRoutes(kernel: Kernel) {
|
|
|
17
17
|
|
|
18
18
|
router.openapi(listFailedJobsRoute, async (c) => {
|
|
19
19
|
const actor = c.get("actor");
|
|
20
|
-
const orgId =
|
|
20
|
+
const orgId = resolveOrgIdForCommerce(actor, kernel.config);
|
|
21
21
|
const limit = Math.min(Number(c.req.query("limit") ?? "50"), 100);
|
|
22
22
|
const conditions = [eq(commerceJobs.status, "failed")];
|
|
23
23
|
// Scope to org unless wildcard admin
|
|
@@ -37,7 +37,7 @@ export function adminJobRoutes(kernel: Kernel) {
|
|
|
37
37
|
// @ts-expect-error -- openapi handler union return type
|
|
38
38
|
router.openapi(retryJobRoute, async (c) => {
|
|
39
39
|
const actor = c.get("actor");
|
|
40
|
-
const orgId =
|
|
40
|
+
const orgId = resolveOrgIdForCommerce(actor, kernel.config);
|
|
41
41
|
const id = c.req.param("id");
|
|
42
42
|
const conditions = [eq(commerceJobs.id, id)];
|
|
43
43
|
// Scope to org unless wildcard admin
|
|
@@ -2,7 +2,7 @@ import { OpenAPIHono } from "@hono/zod-openapi";
|
|
|
2
2
|
import type { Kernel } from "../../../runtime/kernel.js";
|
|
3
3
|
import { listAuditRoute, listEntityAuditRoute } from "../schemas/audit.js";
|
|
4
4
|
import { type AppEnv, requirePerm } from "../utils.js";
|
|
5
|
-
import {
|
|
5
|
+
import { resolveOrgIdForCommerce } from "../../../auth/org.js";
|
|
6
6
|
|
|
7
7
|
export function auditRoutes(kernel: Kernel) {
|
|
8
8
|
const router = new OpenAPIHono<AppEnv>();
|
|
@@ -15,7 +15,7 @@ export function auditRoutes(kernel: Kernel) {
|
|
|
15
15
|
|
|
16
16
|
router.openapi(listAuditRoute, async (c) => {
|
|
17
17
|
const actor = c.get("actor");
|
|
18
|
-
const orgId =
|
|
18
|
+
const orgId = resolveOrgIdForCommerce(actor, kernel.config);
|
|
19
19
|
const entries = await kernel.services.audit.list({
|
|
20
20
|
organizationId: orgId,
|
|
21
21
|
entityType: c.req.query("entityType"),
|
|
@@ -37,7 +37,7 @@ export function auditRoutes(kernel: Kernel) {
|
|
|
37
37
|
|
|
38
38
|
router.openapi(listEntityAuditRoute, async (c) => {
|
|
39
39
|
const actor = c.get("actor");
|
|
40
|
-
const orgId =
|
|
40
|
+
const orgId = resolveOrgIdForCommerce(actor, kernel.config);
|
|
41
41
|
const entries = await kernel.services.audit.listForEntity({
|
|
42
42
|
organizationId: orgId,
|
|
43
43
|
entityType: c.req.param("entityType"),
|
|
@@ -2,16 +2,22 @@ import { OpenAPIHono } from "@hono/zod-openapi";
|
|
|
2
2
|
import type { Kernel } from "../../../runtime/kernel.js";
|
|
3
3
|
import type { CreateCartInput, AddCartItemInput } from "../../../modules/cart/schemas.js";
|
|
4
4
|
import { createCartRoute, addCartItemRoute, updateCartItemQuantityRoute, getCartRoute, removeCartItemRoute, listCartsRoute, recoverCartRoute } from "../schemas/carts.js";
|
|
5
|
-
import { type AppEnv, mapErrorToResponse, mapErrorToStatus, parsePagination } from "../utils.js";
|
|
5
|
+
import { type AppEnv, mapErrorToResponse, mapErrorToStatus, parsePagination, requireMethodPerm, requirePerm } from "../utils.js";
|
|
6
6
|
|
|
7
7
|
export function cartRoutes(kernel: Kernel) {
|
|
8
8
|
const router = new OpenAPIHono<AppEnv>();
|
|
9
9
|
|
|
10
|
+
router.use("/", requireMethodPerm(["GET"], "cart:manage"));
|
|
11
|
+
router.use("/:id/recover", requirePerm("cart:manage"));
|
|
12
|
+
|
|
10
13
|
// @ts-expect-error -- openapi() enforces strict response typing but our handler
|
|
11
14
|
// returns union responses (201 | 400 | 422). The route definition documents the
|
|
12
15
|
// contract; the handler returns dynamic status.
|
|
13
16
|
router.openapi(createCartRoute, async (c) => {
|
|
14
17
|
const actor = c.get("actor");
|
|
18
|
+
if (!actor) {
|
|
19
|
+
return c.json({ error: { code: "NOT_FOUND", message: "Cart not found." } }, 404);
|
|
20
|
+
}
|
|
15
21
|
const result = await kernel.services.cart.create(c.req.valid("json") as CreateCartInput, actor);
|
|
16
22
|
if (!result.ok)
|
|
17
23
|
return c.json(
|
|
@@ -74,17 +80,12 @@ export function cartRoutes(kernel: Kernel) {
|
|
|
74
80
|
// @ts-expect-error -- openapi handler union return type
|
|
75
81
|
router.openapi(getCartRoute, async (c) => {
|
|
76
82
|
const actor = c.get("actor");
|
|
77
|
-
// Guest carts are gated by the cart secret; clients pass it as either
|
|
78
|
-
// ?secret=... or X-Cart-Secret. Customer carts ignore the secret and
|
|
79
|
-
// require ownership instead.
|
|
80
|
-
const secretParam = c.req.query("secret");
|
|
81
83
|
const secretHeader = c.req.header("x-cart-secret") ?? c.req.header("X-Cart-Secret");
|
|
82
|
-
const secret = secretParam ?? secretHeader ?? undefined;
|
|
83
84
|
const result = await kernel.services.cart.getById(
|
|
84
85
|
c.req.param("id"),
|
|
85
86
|
actor,
|
|
86
87
|
undefined,
|
|
87
|
-
|
|
88
|
+
secretHeader ?? undefined,
|
|
88
89
|
);
|
|
89
90
|
if (!result.ok)
|
|
90
91
|
return c.json(
|
|
@@ -98,9 +99,12 @@ export function cartRoutes(kernel: Kernel) {
|
|
|
98
99
|
// returns union responses (201 | 400 | 422). The route definition documents the
|
|
99
100
|
// contract; the handler returns dynamic status.
|
|
100
101
|
router.openapi(addCartItemRoute, async (c) => {
|
|
102
|
+
const secret = c.req.header("x-cart-secret") ?? c.req.header("X-Cart-Secret") ?? undefined;
|
|
101
103
|
const result = await kernel.services.cart.addItem(
|
|
102
104
|
{ ...c.req.valid("json"), cartId: c.req.param("id") } as AddCartItemInput,
|
|
103
105
|
c.get("actor"),
|
|
106
|
+
undefined,
|
|
107
|
+
secret,
|
|
104
108
|
);
|
|
105
109
|
if (!result.ok)
|
|
106
110
|
return c.json(
|
|
@@ -115,6 +119,7 @@ export function cartRoutes(kernel: Kernel) {
|
|
|
115
119
|
// contract; the handler returns dynamic status.
|
|
116
120
|
router.openapi(updateCartItemQuantityRoute, async (c) => {
|
|
117
121
|
const body = c.req.valid("json");
|
|
122
|
+
const secret = c.req.header("x-cart-secret") ?? c.req.header("X-Cart-Secret") ?? undefined;
|
|
118
123
|
const result = await kernel.services.cart.updateQuantity(
|
|
119
124
|
{
|
|
120
125
|
cartId: c.req.param("id"),
|
|
@@ -122,6 +127,8 @@ export function cartRoutes(kernel: Kernel) {
|
|
|
122
127
|
quantity: body.quantity,
|
|
123
128
|
},
|
|
124
129
|
c.get("actor"),
|
|
130
|
+
undefined,
|
|
131
|
+
secret,
|
|
125
132
|
);
|
|
126
133
|
if (!result.ok)
|
|
127
134
|
return c.json(
|
|
@@ -133,10 +140,13 @@ export function cartRoutes(kernel: Kernel) {
|
|
|
133
140
|
|
|
134
141
|
// @ts-expect-error -- openapi handler union return type
|
|
135
142
|
router.openapi(removeCartItemRoute, async (c) => {
|
|
143
|
+
const secret = c.req.header("x-cart-secret") ?? c.req.header("X-Cart-Secret") ?? undefined;
|
|
136
144
|
const result = await kernel.services.cart.removeItem(
|
|
137
145
|
c.req.param("id"),
|
|
138
146
|
c.req.param("itemId"),
|
|
139
147
|
c.get("actor"),
|
|
148
|
+
undefined,
|
|
149
|
+
secret,
|
|
140
150
|
);
|
|
141
151
|
if (!result.ok)
|
|
142
152
|
return c.json(
|
|
@@ -64,8 +64,17 @@ import {
|
|
|
64
64
|
export function catalogRoutes(kernel: Kernel) {
|
|
65
65
|
const router = new OpenAPIHono<AppEnv>();
|
|
66
66
|
|
|
67
|
+
router.use("/entities/*", requirePerm("catalog:read"));
|
|
68
|
+
router.use("/categories", requirePerm("catalog:read"));
|
|
69
|
+
router.use("/brands", requirePerm("catalog:read"));
|
|
70
|
+
|
|
67
71
|
router.use("/entities/:id/custom-fields/:fieldName/approve", requirePerm("catalog:update"));
|
|
68
72
|
router.use("/entities/:id/custom-fields/:fieldName/reject", requirePerm("catalog:update"));
|
|
73
|
+
router.use("/categories/:categoryId", requirePerm("catalog:update"));
|
|
74
|
+
router.use("/categories/:categoryId/*", requirePerm("catalog:update"));
|
|
75
|
+
router.use("/brands/:brandId", requirePerm("catalog:update"));
|
|
76
|
+
router.use("/brands/:brandId/*", requirePerm("catalog:update"));
|
|
77
|
+
router.use("/options/:optionTypeId/values", requirePerm("catalog:update"));
|
|
69
78
|
|
|
70
79
|
// @ts-expect-error -- openapi handler union return type
|
|
71
80
|
router.openapi(listFieldOwnershipRoute, async (c) => {
|
|
@@ -159,6 +168,7 @@ export function catalogRoutes(kernel: Kernel) {
|
|
|
159
168
|
const full = await kernel.services.catalog.getById(
|
|
160
169
|
item.id,
|
|
161
170
|
includeOptions,
|
|
171
|
+
c.get("actor"),
|
|
162
172
|
);
|
|
163
173
|
return full.ok ? full.value : item;
|
|
164
174
|
}),
|
|
@@ -358,6 +368,7 @@ export function catalogRoutes(kernel: Kernel) {
|
|
|
358
368
|
const result = await kernel.services.catalog.getAttributes(
|
|
359
369
|
c.req.param("id"),
|
|
360
370
|
c.req.param("locale"),
|
|
371
|
+
c.get("actor"),
|
|
361
372
|
);
|
|
362
373
|
if (!result.ok)
|
|
363
374
|
return c.json(
|
|
@@ -22,8 +22,8 @@ import type { PluginDb } from "../../../kernel/database/plugin-types.js";
|
|
|
22
22
|
import { type AppEnv, mapErrorToResponse, mapErrorToStatus } from "../utils.js";
|
|
23
23
|
import { isCommerceError } from "../../../kernel/errors.js";
|
|
24
24
|
import { assertPermission } from "../../../auth/permissions.js";
|
|
25
|
-
import {
|
|
26
|
-
import { makeDeterministicId, makeId } from "../../../utils/id.js";
|
|
25
|
+
import { resolveOrgIdForCommerce } from "../../../auth/org.js";
|
|
26
|
+
import { makeDeterministicId, makeId, makeIdempotencyScope } from "../../../utils/id.js";
|
|
27
27
|
import type { ShippingAddress } from "../../../modules/shipping/calculator.js";
|
|
28
28
|
import type { Actor } from "../../../auth/types.js";
|
|
29
29
|
|
|
@@ -85,50 +85,48 @@ export function checkoutRoutes(kernel: Kernel) {
|
|
|
85
85
|
const body = c.req.valid("json");
|
|
86
86
|
|
|
87
87
|
const actor = c.get("actor");
|
|
88
|
+
const cartSecret = c.req.header("x-cart-secret") ?? c.req.header("X-Cart-Secret") ?? undefined;
|
|
88
89
|
|
|
89
|
-
// Idempotent replay
|
|
90
|
-
//
|
|
91
|
-
//
|
|
90
|
+
// Idempotent replay is authorized by the order service before payment runs.
|
|
91
|
+
// This preflight only preserves the no-double-payment fast path; the create
|
|
92
|
+
// primitive repeats the same binding for direct callers and race losers.
|
|
93
|
+
let replay: Awaited<ReturnType<typeof kernel.services.orders.getByIdempotencyKey>> | undefined;
|
|
92
94
|
if (body.idempotencyKey) {
|
|
93
|
-
|
|
95
|
+
replay = await kernel.services.orders.getByIdempotencyKey(
|
|
94
96
|
body.idempotencyKey,
|
|
95
97
|
actor,
|
|
98
|
+
undefined,
|
|
99
|
+
cartSecret,
|
|
96
100
|
);
|
|
97
|
-
if (replay.ok
|
|
98
|
-
|
|
99
|
-
// order. Without this, a same-org customer who supplies/guesses another
|
|
100
|
-
// customer's key would receive that customer's order (PII, totals, lines).
|
|
101
|
-
let canActForOthers = false;
|
|
102
|
-
try {
|
|
103
|
-
assertPermission(actor, "customers:read");
|
|
104
|
-
canActForOthers = true;
|
|
105
|
-
} catch {
|
|
106
|
-
canActForOthers = false;
|
|
107
|
-
}
|
|
108
|
-
const ownCustomer = actor
|
|
109
|
-
? await resolveCheckoutCustomerUuid(kernel.services.customers, actor, undefined)
|
|
110
|
-
: undefined;
|
|
111
|
-
const replayCustomer = replay.value.customerId ?? null;
|
|
112
|
-
const ownsOrder =
|
|
113
|
-
(replayCustomer !== null && replayCustomer === ownCustomer) ||
|
|
114
|
-
(replayCustomer === null && !ownCustomer);
|
|
115
|
-
if (canActForOthers || ownsOrder) {
|
|
116
|
-
return c.json({ data: replay.value }, 201);
|
|
117
|
-
}
|
|
118
|
-
return c.json(
|
|
119
|
-
{
|
|
120
|
-
error: {
|
|
121
|
-
code: "IDEMPOTENCY_CONFLICT",
|
|
122
|
-
message: "Idempotency key does not belong to this account.",
|
|
123
|
-
},
|
|
124
|
-
},
|
|
125
|
-
409,
|
|
126
|
-
);
|
|
101
|
+
if (!replay.ok) {
|
|
102
|
+
return c.json(mapErrorToResponse(replay.error), mapErrorToStatus(replay.error));
|
|
127
103
|
}
|
|
104
|
+
if (replay.value) return c.json({ data: replay.value }, 201);
|
|
128
105
|
}
|
|
129
106
|
|
|
107
|
+
// Authenticate the cart once at the HTTP boundary. Guest checkout is
|
|
108
|
+
// authorized by the cart secret; customer checkout is authorized by the
|
|
109
|
+
// customer cart ownership check. The hook pipeline uses its explicit
|
|
110
|
+
// internal cart-read path instead of carrying this credential inward.
|
|
111
|
+
const cartAccess = await kernel.services.cart.getById(
|
|
112
|
+
body.cartId,
|
|
113
|
+
actor,
|
|
114
|
+
undefined,
|
|
115
|
+
cartSecret,
|
|
116
|
+
);
|
|
117
|
+
if (!cartAccess.ok) {
|
|
118
|
+
return c.json(
|
|
119
|
+
mapErrorToResponse(cartAccess.error),
|
|
120
|
+
mapErrorToStatus(cartAccess.error),
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
const idempotencyScope = body.idempotencyKey
|
|
124
|
+
? await makeIdempotencyScope(actor, cartSecret, body.cartId)
|
|
125
|
+
: undefined;
|
|
130
126
|
const orderId = body.idempotencyKey
|
|
131
|
-
? await makeDeterministicId(
|
|
127
|
+
? await makeDeterministicId(
|
|
128
|
+
`checkout:${resolveOrgIdForCommerce(actor, kernel.config)}:${idempotencyScope}:${body.idempotencyKey}`,
|
|
129
|
+
)
|
|
132
130
|
: makeId();
|
|
133
131
|
const checkoutData: CheckoutData = {
|
|
134
132
|
checkoutId: makeId(),
|
|
@@ -203,6 +201,7 @@ export function checkoutRoutes(kernel: Kernel) {
|
|
|
203
201
|
context: { moduleName: "checkout" },
|
|
204
202
|
origin: "rest",
|
|
205
203
|
database: { db: kernel.database.db as PluginDb },
|
|
204
|
+
commerceConfig: kernel.config,
|
|
206
205
|
});
|
|
207
206
|
|
|
208
207
|
let pipelineStage = "validate-and-calculate";
|
|
@@ -293,6 +292,7 @@ export function checkoutRoutes(kernel: Kernel) {
|
|
|
293
292
|
pipelineStage = "create-order";
|
|
294
293
|
const order = await kernel.services.orders.create(orderPayload, actor, undefined, {
|
|
295
294
|
trustedPricing: true,
|
|
295
|
+
...(cartSecret !== undefined ? { guestCredential: cartSecret } : {}),
|
|
296
296
|
});
|
|
297
297
|
|
|
298
298
|
if (!order.ok) {
|
|
@@ -379,7 +379,10 @@ export function checkoutRoutes(kernel: Kernel) {
|
|
|
379
379
|
// Validation, payment authorization, or compensated completion may fail
|
|
380
380
|
// after the cart's atomic active -> checking_out claim. Release only
|
|
381
381
|
// that transient state so the shopper can correct the problem and retry.
|
|
382
|
-
|
|
382
|
+
// Releasing is scoped to this attempt's claim token: an attempt that lost
|
|
383
|
+
// the claim never held it, so its failure cannot reopen a cart another
|
|
384
|
+
// checkout is still working through.
|
|
385
|
+
await kernel.services.cart.releaseCheckoutClaim(body.cartId, checkoutData.checkoutId).catch(
|
|
383
386
|
(releaseError: unknown) => {
|
|
384
387
|
console.error("[checkout] Failed to release cart claim:", {
|
|
385
388
|
cartId: body.cartId,
|