diff --git a/CLAUDE.md b/CLAUDE.md
index d67e6c3d5..b90d3b1dd 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -24,6 +24,7 @@ Monorepo for the Ethio Djibouti Railway (EDR) digital platform. Contains the Fre
| ---------------------- | ---------------------------------------------------------------------------------- |
| `@edr/types` | Shared TypeScript interfaces and enums |
| `@edr/api-common` | Shared NestJS decorators, filters, interceptors, pipes, BaseEntity, BaseRepository |
+| `@edr/iam-seed` | IAM baseline seeder for the apps sharing the `iam` schema (freight + passenger) |
| `@edr/ui-common` | Shared React components and theme |
| `@edr/eslint-config` | Shared ESLint configurations (base/nestjs/react) |
| `@edr/tsconfig` | Shared TypeScript configurations |
diff --git a/apps/edr-freight-api/.env.example b/apps/edr-freight-api/.env.example
index d80ce75c6..58fb60b18 100644
--- a/apps/edr-freight-api/.env.example
+++ b/apps/edr-freight-api/.env.example
@@ -42,8 +42,17 @@ JWT_REFRESH_TOKEN_EXPIRES=7d
# IAM seed defaults (used by @tria-plc/iamapi-common on first boot)
SUPER_ADMIN_EMAIL=superadmin@tria.com
SUPER_ADMIN_PHONE=
+# Super-admin password. Falls back to DEFAULT_PASSWORD when empty.
+SUPER_ADMIN_DEFAULT_PASSWORD=
DEFAULT_PASSWORD=password@tria
+# IAM baseline shared with edr-passenger-api (roles, IAM app + permissions,
+# position types, organization types + default units, org/unit settings, super
+# admin). Replaces the seeder that shipped inside @tria-plc/iamapi-common — see
+# packages/iam-seed. Seeds by DEFAULT when unset; every write is insert-only.
+# Set to false to opt out.
+SEED_IAM_BASELINE=true
+
# Freight org + staff (bookings / rule-engine IAM)
SEED_EDR_ORG=true
SEED_FREIGHT_STAFF=true
diff --git a/apps/edr-freight-api/package.json b/apps/edr-freight-api/package.json
index 66909a037..0531d056b 100644
--- a/apps/edr-freight-api/package.json
+++ b/apps/edr-freight-api/package.json
@@ -35,12 +35,12 @@
"iam:migration:run": "pnpm run iam:typeorm:cli migration:run",
"iam:migration:revert": "pnpm run iam:typeorm:cli migration:revert",
"iam:migration:show": "pnpm run iam:typeorm:cli migration:show",
- "iam:seed:run": "cross-env APP_MODULE_PATH=./dist/app.module dotenv -- node ./node_modules/@tria-plc/iamapi-common/dist/db/seed.cli.js",
"migrate": "ts-node -r tsconfig-paths/register src/scripts/run-migrations.ts",
"script": "ts-node -r tsconfig-paths/register src/scripts/main.ts"
},
"dependencies": {
"@edr/api-common": "workspace:*",
+ "@edr/iam-seed": "workspace:*",
"@edr/payment-providers": "workspace:*",
"@edr/types": "workspace:*",
"@golevelup/nestjs-rabbitmq": "^5.5.0",
diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts
index aab9e0e4a..64df33f35 100644
--- a/apps/edr-freight-api/src/app.module.ts
+++ b/apps/edr-freight-api/src/app.module.ts
@@ -12,7 +12,8 @@ import {
ensurePostgresSchemas,
APPLICATION_SEARCH_PATH,
} from "./config/ensure-postgres-schemas";
-import { IamModule, DataSeeder } from "@tria-plc/iamapi-common";
+import { IamBaselineSeeder, IamSeedModule } from "@edr/iam-seed";
+import { IamModule } from "@tria-plc/iamapi-common";
import { SharedAuthModule } from "@tria-plc/api-common/modules/auth/shared-auth.module";
import appConfig from "./config/app.config";
@@ -153,6 +154,18 @@ import { LoggerMiddleware } from "./logger.middleware";
applications: [EDR_FREIGHT_APPLICATION],
permissions: EDR_FREIGHT_PERMISSIONS,
}),
+ // Replaces the package's DataSeeder. Shared with edr-passenger-api, which
+ // seeds the same `iam` schema — see packages/iam-seed.
+ IamSeedModule.forRoot({
+ superAdmin: {
+ username: "superadmin",
+ name: { am: "ሱፐር አድሚን", en: "Super Admin" },
+ roleKey: "super_admin",
+ organizationKey: "edr_freight",
+ unitKey: "edr_freight_app",
+ fallbackEmail: "superadmin@tria.com",
+ },
+ }),
BookingsModule,
ContractsModule,
SignaturesModule,
@@ -231,7 +244,7 @@ import { LoggerMiddleware } from "./logger.middleware";
})
export class AppModule implements OnApplicationBootstrap {
constructor(
- private readonly seeder: DataSeeder,
+ private readonly iamBaselineSeeder: IamBaselineSeeder,
private readonly edrOrgSeeder: EdrOrgSeeder,
private readonly freightPositionsSeeder: FreightPositionsSeeder,
private readonly fileUploadSettingsSeeder: FileUploadSettingsSeeder,
@@ -261,13 +274,22 @@ export class AppModule implements OnApplicationBootstrap {
// Permissions foundation — keep enabled:
// freightPermissionKeyMigration → renames legacy permission keys
- // seeder (IAM DataSeeder) → seeds the IAM app, roles, permissions
// edrOrgSeeder → seeds org/unit + the Permission catalog
+ // iamBaselineSeeder → @edr/iam-seed: IAM app, roles, permissions,
+ // position types, organization types +
+ // default units, org/unit settings and the
+ // super-admin account. Replaces the package's
+ // DataSeeder, and is shared with
+ // edr-passenger-api so one writer owns the
+ // `iam` schema. Runs after edrOrgSeeder
+ // because the super admin attaches to the
+ // edr_freight org/unit.
+ // Writes nothing unless SEED_IAM_BASELINE=true.
// freightPositionsSeeder → seeds Position + PositionPermission rows
// (depends on edrOrgSeeder, must run after)
await this.freightPermissionKeyMigrationSeeder.run();
- await this.seeder.run();
await this.edrOrgSeeder.run();
+ await this.iamBaselineSeeder.run();
await this.freightPositionsSeeder.run();
// File upload settings — keep enabled.
diff --git a/apps/edr-freight-api/src/common/booking-guards.ts b/apps/edr-freight-api/src/common/booking-guards.ts
index 92958b364..854594ffc 100644
--- a/apps/edr-freight-api/src/common/booking-guards.ts
+++ b/apps/edr-freight-api/src/common/booking-guards.ts
@@ -23,6 +23,14 @@ export const StaffReference = () => applyDecorators(UseGuards(JwtGuard));
export const BookingView = () => BookingStaff(FREIGHT_PERMS.bookings.view);
+/**
+ * The document-review countdown in the backoffice header. Its own permission so
+ * it can be granted to exactly the position types that decide operation
+ * requests, instead of every holder of bookings:view.
+ */
+export const BookingDocReviewAlert = () =>
+ BookingStaff(FREIGHT_PERMS.bookings.docReviewAlert);
+
export const TrainSchedulingView = () =>
BookingStaff(FREIGHT_PERMS.trainScheduling.view);
@@ -73,6 +81,32 @@ export const WagonTransferFulfill = () =>
export const WagonTransferHistoryAll = () =>
BookingStaff(FREIGHT_PERMS.wagons.transferHistoryAll);
+/**
+ * Open the transfer-requests desk. `wagons:view` is accepted as a one-of
+ * fallback so staff who could already reach the queue keep it without a
+ * re-grant — same pattern the granular fleet keys use.
+ */
+export const WagonTransferView = () =>
+ BookingStaff([FREIGHT_PERMS.wagons.transferView, FREIGHT_PERMS.wagons.view]);
+
+/** Withdraw a request that has not moved any wagon yet. */
+export const WagonTransferCancel = () =>
+ BookingStaff([
+ FREIGHT_PERMS.wagons.transferCancel,
+ FREIGHT_PERMS.wagons.transferRequest,
+ ]);
+
+/**
+ * End a request short of the requested count. Whoever may move wagons may also
+ * declare the yard has no more to give, so fulfil is accepted alongside the
+ * dedicated key.
+ */
+export const WagonTransferCloseShort = () =>
+ BookingStaff([
+ FREIGHT_PERMS.wagons.transferCloseShort,
+ FREIGHT_PERMS.wagons.transferFulfill,
+ ]);
+
/** Org-administration endpoints (user mgmt, billing config, company CRUD, settings). */
export const FreightAdmin = () => BookingStaff(FREIGHT_PERMS.admin);
diff --git a/apps/edr-freight-api/src/common/freight-permission.hazardous.spec.ts b/apps/edr-freight-api/src/common/freight-permission.hazardous.spec.ts
new file mode 100644
index 000000000..4658a5434
--- /dev/null
+++ b/apps/edr-freight-api/src/common/freight-permission.hazardous.spec.ts
@@ -0,0 +1,47 @@
+import { ForbiddenException } from '@nestjs/common';
+
+import {
+ assertCanApproveContractStep,
+ canEditContractStep,
+} from './freight-permission.util';
+import { FREIGHT_PERMS } from '../seed/freight-permissions.registry';
+
+const userWith = (...keys: string[]) => ({
+ permissions: keys.map((key) => ({ key })),
+});
+
+describe('hazardous contract approval steps', () => {
+ it('rejects an approver who only holds ordinary contract-approve permissions', () => {
+ // The blanket "any contract approve permission" fallback must NOT reach
+ // dangerous goods — that is the whole point of the dedicated desks.
+ const lineStaff = userWith(FREIGHT_PERMS.contracts.approveLineStaff);
+
+ expect(() =>
+ assertCanApproveContractStep(lineStaff, 'HAZARDOUS_APPROVAL_ONE'),
+ ).toThrow(ForbiddenException);
+ expect(canEditContractStep(lineStaff, 'HAZARDOUS_APPROVAL_ONE')).toBe(false);
+ });
+
+ it('accepts only the matching hazardous permission', () => {
+ const first = userWith(FREIGHT_PERMS.contracts.hazardousApprovalOne);
+
+ expect(() =>
+ assertCanApproveContractStep(first, 'HAZARDOUS_APPROVAL_ONE'),
+ ).not.toThrow();
+ // Holding step one does not confer step two.
+ expect(() =>
+ assertCanApproveContractStep(first, 'HAZARDOUS_APPROVAL_TWO'),
+ ).toThrow(ForbiddenException);
+ });
+
+ it('does not let a hazardous approver stand in for the commercial chain', () => {
+ const hazardOnly = userWith(
+ FREIGHT_PERMS.contracts.hazardousApprovalOne,
+ FREIGHT_PERMS.contracts.hazardousApprovalTwo,
+ );
+
+ expect(() => assertCanApproveContractStep(hazardOnly, 'CEO')).toThrow(
+ ForbiddenException,
+ );
+ });
+});
diff --git a/apps/edr-freight-api/src/common/freight-permission.util.ts b/apps/edr-freight-api/src/common/freight-permission.util.ts
index 429c910d3..56c0e77c2 100644
--- a/apps/edr-freight-api/src/common/freight-permission.util.ts
+++ b/apps/edr-freight-api/src/common/freight-permission.util.ts
@@ -151,6 +151,24 @@ const APPROVE_ROLE_PERMISSION: Record Name: {{signerDisplayName}} Name: {{signerDisplayName}}
Witnesses
-| Name | Signature | Date | |
|---|---|---|---|
| 1. | |||
| 2. |