@astram/identity 0.1.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 (44) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +16 -0
  3. package/dist/branch.object.d.ts +6 -0
  4. package/dist/branch.object.d.ts.map +1 -0
  5. package/dist/branch.object.js +37 -0
  6. package/dist/company-branch.object.d.ts +11 -0
  7. package/dist/company-branch.object.d.ts.map +1 -0
  8. package/dist/company-branch.object.js +42 -0
  9. package/dist/company-department.object.d.ts +13 -0
  10. package/dist/company-department.object.d.ts.map +1 -0
  11. package/dist/company-department.object.js +44 -0
  12. package/dist/company-membership.object.d.ts +16 -0
  13. package/dist/company-membership.object.d.ts.map +1 -0
  14. package/dist/company-membership.object.js +63 -0
  15. package/dist/company.object.d.ts +10 -0
  16. package/dist/company.object.d.ts.map +1 -0
  17. package/dist/company.object.js +64 -0
  18. package/dist/department.object.d.ts +6 -0
  19. package/dist/department.object.d.ts.map +1 -0
  20. package/dist/department.object.js +37 -0
  21. package/dist/index.d.ts +20 -0
  22. package/dist/index.d.ts.map +1 -0
  23. package/dist/index.js +37 -0
  24. package/dist/membership-posting.object.d.ts +16 -0
  25. package/dist/membership-posting.object.d.ts.map +1 -0
  26. package/dist/membership-posting.object.js +76 -0
  27. package/dist/migrations-dir.d.ts +2 -0
  28. package/dist/migrations-dir.d.ts.map +1 -0
  29. package/dist/migrations-dir.js +14 -0
  30. package/dist/role.object.d.ts +7 -0
  31. package/dist/role.object.d.ts.map +1 -0
  32. package/dist/role.object.js +42 -0
  33. package/dist/schema.d.ts +3 -0
  34. package/dist/schema.d.ts.map +1 -0
  35. package/dist/schema.js +5 -0
  36. package/dist/user.object.d.ts +13 -0
  37. package/dist/user.object.d.ts.map +1 -0
  38. package/dist/user.object.js +59 -0
  39. package/migrations/1710000000001-CreateIdentityOrgModel.cjs +100 -0
  40. package/migrations/1710000000002-CreateMembershipAndPostings.cjs +111 -0
  41. package/migrations/1710000000003-CreateIdentityCredential.cjs +58 -0
  42. package/migrations/1710000000004-CreateSessionTables.cjs +79 -0
  43. package/migrations/1710000000005-CreateMfaAndTokenSurface.cjs +88 -0
  44. package/package.json +50 -0
@@ -0,0 +1,42 @@
1
+ "use strict";
2
+ var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
3
+ var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
4
+ if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
5
+ else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
6
+ return c > 3 && r && Object.defineProperty(target, key, r), r;
7
+ };
8
+ var __metadata = (this && this.__metadata) || function (k, v) {
9
+ if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
10
+ };
11
+ Object.defineProperty(exports, "__esModule", { value: true });
12
+ exports.Role = void 0;
13
+ const engine_1 = require("@astram/engine");
14
+ const schema_1 = require("./schema");
15
+ /** roles — global catalog. */
16
+ let Role = class Role {
17
+ uuid;
18
+ name;
19
+ createdAt;
20
+ };
21
+ exports.Role = Role;
22
+ __decorate([
23
+ (0, engine_1.Field)({ column: 'r_uuid', type: 'uuid', role: 'primaryKey' }),
24
+ __metadata("design:type", String)
25
+ ], Role.prototype, "uuid", void 0);
26
+ __decorate([
27
+ (0, engine_1.Field)({ column: 'r_name', type: 'string', required: true }),
28
+ __metadata("design:type", String)
29
+ ], Role.prototype, "name", void 0);
30
+ __decorate([
31
+ (0, engine_1.Field)({ column: 'r_created_at', type: 'timestamp' }),
32
+ __metadata("design:type", String)
33
+ ], Role.prototype, "createdAt", void 0);
34
+ exports.Role = Role = __decorate([
35
+ (0, engine_1.ObjectType)({
36
+ typeKey: 'role',
37
+ table: 'roles',
38
+ schema: schema_1.SCHEMA,
39
+ access: { plane: 'control', system: true },
40
+ }),
41
+ (0, engine_1.PrimaryKey)({ kind: 'uuid', column: 'r_uuid' })
42
+ ], Role);
@@ -0,0 +1,3 @@
1
+ /** The physical schema every identity table lives in (single-source). */
2
+ export declare const SCHEMA = "shield_app";
3
+ //# sourceMappingURL=schema.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"schema.d.ts","sourceRoot":"","sources":["../src/schema.ts"],"names":[],"mappings":"AAAA,yEAAyE;AACzE,eAAO,MAAM,MAAM,eAAe,CAAC"}
package/dist/schema.js ADDED
@@ -0,0 +1,5 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.SCHEMA = void 0;
4
+ /** The physical schema every identity table lives in (single-source). */
5
+ exports.SCHEMA = 'shield_app';
@@ -0,0 +1,13 @@
1
+ /**
2
+ * users — the principal. `u_uuid` links to identity_credential.ic_principal_id
3
+ * (the hash lives framework-side). Users are not tenant-stamped (they belong to
4
+ * many companies via company_membership); scoping is via the membership, not a column.
5
+ */
6
+ export declare class User {
7
+ uuid: string;
8
+ username: string;
9
+ email: string;
10
+ status: string;
11
+ createdAt: string;
12
+ }
13
+ //# sourceMappingURL=user.object.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"user.object.d.ts","sourceRoot":"","sources":["../src/user.object.ts"],"names":[],"mappings":"AAGA;;;;GAIG;AACH,qBAUa,IAAI;IAEf,IAAI,EAAG,MAAM,CAAC;IAGd,QAAQ,EAAG,MAAM,CAAC;IAGlB,KAAK,EAAG,MAAM,CAAC;IAGf,MAAM,EAAG,MAAM,CAAC;IAGhB,SAAS,EAAG,MAAM,CAAC;CACpB"}
@@ -0,0 +1,59 @@
1
+ "use strict";
2
+ var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
3
+ var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
4
+ if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
5
+ else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
6
+ return c > 3 && r && Object.defineProperty(target, key, r), r;
7
+ };
8
+ var __metadata = (this && this.__metadata) || function (k, v) {
9
+ if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
10
+ };
11
+ Object.defineProperty(exports, "__esModule", { value: true });
12
+ exports.User = void 0;
13
+ const engine_1 = require("@astram/engine");
14
+ const schema_1 = require("./schema");
15
+ /**
16
+ * users — the principal. `u_uuid` links to identity_credential.ic_principal_id
17
+ * (the hash lives framework-side). Users are not tenant-stamped (they belong to
18
+ * many companies via company_membership); scoping is via the membership, not a column.
19
+ */
20
+ let User = class User {
21
+ uuid;
22
+ username;
23
+ email;
24
+ status;
25
+ createdAt;
26
+ };
27
+ exports.User = User;
28
+ __decorate([
29
+ (0, engine_1.Field)({ column: 'u_uuid', type: 'uuid', role: 'primaryKey' }),
30
+ __metadata("design:type", String)
31
+ ], User.prototype, "uuid", void 0);
32
+ __decorate([
33
+ (0, engine_1.Field)({ column: 'u_username', type: 'string', required: true, role: 'username' }),
34
+ __metadata("design:type", String)
35
+ ], User.prototype, "username", void 0);
36
+ __decorate([
37
+ (0, engine_1.Field)({ column: 'u_email', type: 'string' }),
38
+ __metadata("design:type", String)
39
+ ], User.prototype, "email", void 0);
40
+ __decorate([
41
+ (0, engine_1.Field)({ column: 'u_status', type: 'string', role: 'status' }),
42
+ __metadata("design:type", String)
43
+ ], User.prototype, "status", void 0);
44
+ __decorate([
45
+ (0, engine_1.Field)({ column: 'u_created_at', type: 'timestamp' }),
46
+ __metadata("design:type", String)
47
+ ], User.prototype, "createdAt", void 0);
48
+ exports.User = User = __decorate([
49
+ (0, engine_1.ObjectType)({
50
+ typeKey: 'user',
51
+ table: 'users',
52
+ schema: schema_1.SCHEMA,
53
+ // plane:'control' — `user` is identity administration (the identity-administrator model: the
54
+ // capped root, the identity administrator, may list/read AND create/update/delete users). Uniform
55
+ // across all actions (read reclassified control 2026-07-16), so plane is per-type.
56
+ access: { plane: 'control', system: true },
57
+ }),
58
+ (0, engine_1.PrimaryKey)({ kind: 'uuid', column: 'u_uuid' })
59
+ ], User);
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Framework identity migration — the org/identity core in `shield_app`.
3
+ *
4
+ * Creates companies (the tenant), users (the principal; login hash lives in
5
+ * shield_identity.identity_credential), and the global catalogs roles/departments/
6
+ * branches. Final shape — NO legacy junctions (user_companies/user_company_roles/
7
+ * company_roles/user_departments/user_branches are never created; the first-class
8
+ * membership model is created by 1710000000002).
9
+ *
10
+ * Framework-owned (relocated from astram3 migration 1720000000001 + the superuser flag
11
+ * from 1720000000007). Table/column names are read from the @astram/identity object
12
+ * registry so the DDL and runtime queries share one source of truth. Idempotent
13
+ * (IF NOT EXISTS). No hardcoded uuids. Schema is qualified explicitly (shield_app).
14
+ */
15
+ const { Company, User, Role, Department, Branch } = require('@astram/identity');
16
+ const { columnForRole, reflectObjectType } = require('@astram/engine');
17
+
18
+ const SCHEMA = 'shield_app';
19
+ const TS = 'TIMESTAMP(0) NOT NULL DEFAULT CURRENT_TIMESTAMP';
20
+
21
+ // Reflect the @ObjectType/@Field metadata off each decorated class into its registration
22
+ // (`{ typeKey, table, fields, primaryKey, access }`) ONCE — `columnForRole`/`.table` read
23
+ // this registration, not the raw class (the class itself carries no static `.table`).
24
+ const CompanyReg = reflectObjectType(Company);
25
+ const UserReg = reflectObjectType(User);
26
+ const RoleReg = reflectObjectType(Role);
27
+ const DepartmentReg = reflectObjectType(Department);
28
+ const BranchReg = reflectObjectType(Branch);
29
+
30
+ // Resolve a column by role from a reflected object registration (single source of truth).
31
+ const col = (reg, role) => columnForRole(reg, role);
32
+
33
+ async function up({ query }) {
34
+ // companies (the tenant) — hierarchy + type + status.
35
+ await query(`CREATE TABLE IF NOT EXISTS \`${SCHEMA}\`.\`${CompanyReg.table}\` (
36
+ ${col(CompanyReg, 'primaryKey')} VARCHAR(36) NOT NULL,
37
+ c_name VARCHAR(200) NOT NULL,
38
+ c_parent_uuid VARCHAR(36) NULL,
39
+ c_type VARCHAR(50) NOT NULL DEFAULT 'standard',
40
+ ${col(CompanyReg, 'status')} VARCHAR(20) NOT NULL DEFAULT 'active',
41
+ c_created_at ${TS},
42
+ PRIMARY KEY (${col(CompanyReg, 'primaryKey')}),
43
+ INDEX idx_companies_parent (c_parent_uuid),
44
+ INDEX idx_companies_status (${col(CompanyReg, 'status')}),
45
+ CONSTRAINT FK_companies_parent FOREIGN KEY (c_parent_uuid)
46
+ REFERENCES \`${SCHEMA}\`.\`${CompanyReg.table}\` (${col(CompanyReg, 'primaryKey')}) ON DELETE SET NULL
47
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`);
48
+
49
+ // users (the principal). u_is_platform_superuser is the capped-root source of truth
50
+ // (read by the app policy layer via SQL, not an object field) — created here so the
51
+ // framework owns the column, DEFAULT 0.
52
+ await query(`CREATE TABLE IF NOT EXISTS \`${SCHEMA}\`.\`${UserReg.table}\` (
53
+ ${col(UserReg, 'primaryKey')} VARCHAR(36) NOT NULL,
54
+ ${col(UserReg, 'username')} VARCHAR(150) NOT NULL,
55
+ u_email VARCHAR(255) NULL,
56
+ ${col(UserReg, 'status')} VARCHAR(20) NOT NULL DEFAULT 'active',
57
+ u_created_at ${TS},
58
+ u_is_platform_superuser TINYINT(1) NOT NULL DEFAULT 0,
59
+ PRIMARY KEY (${col(UserReg, 'primaryKey')}),
60
+ UNIQUE KEY IDX_users_username (${col(UserReg, 'username')}),
61
+ INDEX idx_users_status (${col(UserReg, 'status')})
62
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`);
63
+
64
+ // roles — global catalog.
65
+ await query(`CREATE TABLE IF NOT EXISTS \`${SCHEMA}\`.\`${RoleReg.table}\` (
66
+ ${col(RoleReg, 'primaryKey')} VARCHAR(36) NOT NULL,
67
+ r_name VARCHAR(150) NOT NULL,
68
+ r_created_at ${TS},
69
+ PRIMARY KEY (${col(RoleReg, 'primaryKey')}),
70
+ UNIQUE KEY IDX_roles_name (r_name)
71
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`);
72
+
73
+ // departments — global catalog.
74
+ await query(`CREATE TABLE IF NOT EXISTS \`${SCHEMA}\`.\`${DepartmentReg.table}\` (
75
+ ${col(DepartmentReg, 'primaryKey')} VARCHAR(36) NOT NULL,
76
+ d_name VARCHAR(150) NOT NULL,
77
+ d_created_at ${TS},
78
+ PRIMARY KEY (${col(DepartmentReg, 'primaryKey')}),
79
+ UNIQUE KEY IDX_departments_name (d_name)
80
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`);
81
+
82
+ // branches — global catalog.
83
+ await query(`CREATE TABLE IF NOT EXISTS \`${SCHEMA}\`.\`${BranchReg.table}\` (
84
+ ${col(BranchReg, 'primaryKey')} VARCHAR(36) NOT NULL,
85
+ b_name VARCHAR(150) NOT NULL,
86
+ b_created_at ${TS},
87
+ PRIMARY KEY (${col(BranchReg, 'primaryKey')}),
88
+ UNIQUE KEY IDX_branches_name (b_name)
89
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`);
90
+ }
91
+
92
+ async function down({ query }) {
93
+ // Reverse dependency order — membership tables (1710000000002) drop first via their
94
+ // own down(); here drop the catalogs + principal + tenant.
95
+ for (const t of [BranchReg.table, DepartmentReg.table, RoleReg.table, UserReg.table, CompanyReg.table]) {
96
+ await query(`DROP TABLE IF EXISTS \`${SCHEMA}\`.\`${t}\``);
97
+ }
98
+ }
99
+
100
+ module.exports = { up, down };
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Framework identity migration — first-class membership + postings + company junctions
3
+ * in `shield_app`. Runs AFTER 1710000000001 (needs users/companies/roles/dept/branch).
4
+ *
5
+ * company_membership — the membership card (active-tenant pointer via cm_is_active).
6
+ * membership_posting — the time-bounded role+dept?+branch? assignment. The effective
7
+ * window is BIGINT epoch-ms (mp_effective_from_ms/until_ms) — final shape, timezone-
8
+ * immune (relocated from astram3 -005 + -050). company_departments/company_branches —
9
+ * tenant-scoped catalog junctions (from -001).
10
+ *
11
+ * Framework-owned. Names from the @astram/identity registry. Idempotent. No uuids.
12
+ */
13
+ const {
14
+ Company, User, Role, Department, Branch,
15
+ CompanyMembership, MembershipPosting, CompanyDepartment, CompanyBranch,
16
+ } = require('@astram/identity');
17
+ const { columnForRole, reflectObjectType } = require('@astram/engine');
18
+
19
+ const SCHEMA = 'shield_app';
20
+ const TS = 'TIMESTAMP(0) NOT NULL DEFAULT CURRENT_TIMESTAMP';
21
+
22
+ // The decorated classes carry NO static `.table` — @ObjectType/@Field metadata is read
23
+ // via reflectObjectType() into a registration ({ typeKey, table, fields, primaryKey }).
24
+ // columnForRole + the table name both read that registration, NOT the raw class. Reflect
25
+ // each once at module scope. (Task 3 hit this: `Company.table` is undefined on the class.)
26
+ const CompanyReg = reflectObjectType(Company);
27
+ const UserReg = reflectObjectType(User);
28
+ const RoleReg = reflectObjectType(Role);
29
+ const DepartmentReg = reflectObjectType(Department);
30
+ const BranchReg = reflectObjectType(Branch);
31
+ const CompanyMembershipReg = reflectObjectType(CompanyMembership);
32
+ const MembershipPostingReg = reflectObjectType(MembershipPosting);
33
+ const CompanyDepartmentReg = reflectObjectType(CompanyDepartment);
34
+ const CompanyBranchReg = reflectObjectType(CompanyBranch);
35
+ const col = (reg, role) => columnForRole(reg, role);
36
+
37
+ async function up({ query }) {
38
+ // company_membership — first-class membership + active-tenant pointer.
39
+ await query(`CREATE TABLE IF NOT EXISTS \`${SCHEMA}\`.\`${CompanyMembershipReg.table}\` (
40
+ ${col(CompanyMembershipReg, 'primaryKey')} VARCHAR(36) NOT NULL,
41
+ cm_user_uuid VARCHAR(36) NOT NULL,
42
+ cm_company_uuid VARCHAR(36) NOT NULL,
43
+ ${col(CompanyMembershipReg, 'active')} TINYINT(1) NOT NULL DEFAULT 0,
44
+ ${col(CompanyMembershipReg, 'status')} VARCHAR(20) NOT NULL DEFAULT 'active',
45
+ cm_created_at ${TS},
46
+ cm_created_by VARCHAR(36) NULL,
47
+ PRIMARY KEY (${col(CompanyMembershipReg, 'primaryKey')}),
48
+ UNIQUE KEY IDX_cm_user_company (cm_user_uuid, cm_company_uuid),
49
+ INDEX idx_cm_company (cm_company_uuid),
50
+ INDEX idx_cm_active (cm_user_uuid, ${col(CompanyMembershipReg, 'active')}),
51
+ CONSTRAINT FK_cm_user FOREIGN KEY (cm_user_uuid)
52
+ REFERENCES \`${SCHEMA}\`.\`${UserReg.table}\` (${col(UserReg, 'primaryKey')}) ON DELETE CASCADE,
53
+ CONSTRAINT FK_cm_company FOREIGN KEY (cm_company_uuid)
54
+ REFERENCES \`${SCHEMA}\`.\`${CompanyReg.table}\` (${col(CompanyReg, 'primaryKey')}) ON DELETE CASCADE
55
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`);
56
+
57
+ // membership_posting — time-bounded role assignment, epoch-ms window (final shape).
58
+ await query(`CREATE TABLE IF NOT EXISTS \`${SCHEMA}\`.\`${MembershipPostingReg.table}\` (
59
+ ${col(MembershipPostingReg, 'primaryKey')} VARCHAR(36) NOT NULL,
60
+ mp_membership_uuid VARCHAR(36) NOT NULL,
61
+ mp_role_uuid VARCHAR(36) NOT NULL,
62
+ mp_department_uuid VARCHAR(36) NULL,
63
+ mp_branch_uuid VARCHAR(36) NULL,
64
+ ${col(MembershipPostingReg, 'effectiveFrom')} BIGINT NULL,
65
+ ${col(MembershipPostingReg, 'effectiveUntil')} BIGINT NULL,
66
+ mp_created_at ${TS},
67
+ mp_created_by VARCHAR(36) NULL,
68
+ PRIMARY KEY (${col(MembershipPostingReg, 'primaryKey')}),
69
+ UNIQUE KEY IDX_mp_unique (mp_membership_uuid, mp_role_uuid, mp_department_uuid, mp_branch_uuid),
70
+ INDEX idx_mp_membership (mp_membership_uuid),
71
+ INDEX idx_mp_role (mp_role_uuid),
72
+ CONSTRAINT FK_mp_membership FOREIGN KEY (mp_membership_uuid)
73
+ REFERENCES \`${SCHEMA}\`.\`${CompanyMembershipReg.table}\` (${col(CompanyMembershipReg, 'primaryKey')}) ON DELETE CASCADE,
74
+ CONSTRAINT FK_mp_role FOREIGN KEY (mp_role_uuid)
75
+ REFERENCES \`${SCHEMA}\`.\`${RoleReg.table}\` (${col(RoleReg, 'primaryKey')}) ON DELETE CASCADE,
76
+ CONSTRAINT FK_mp_department FOREIGN KEY (mp_department_uuid)
77
+ REFERENCES \`${SCHEMA}\`.\`${DepartmentReg.table}\` (${col(DepartmentReg, 'primaryKey')}) ON DELETE SET NULL,
78
+ CONSTRAINT FK_mp_branch FOREIGN KEY (mp_branch_uuid)
79
+ REFERENCES \`${SCHEMA}\`.\`${BranchReg.table}\` (${col(BranchReg, 'primaryKey')}) ON DELETE SET NULL
80
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`);
81
+
82
+ // company_departments — tenant-scoped department catalog junction.
83
+ await query(`CREATE TABLE IF NOT EXISTS \`${SCHEMA}\`.\`${CompanyDepartmentReg.table}\` (
84
+ cd_company_uuid VARCHAR(36) NOT NULL,
85
+ cd_department_uuid VARCHAR(36) NOT NULL,
86
+ PRIMARY KEY (cd_company_uuid, cd_department_uuid),
87
+ CONSTRAINT FK_cd_company FOREIGN KEY (cd_company_uuid)
88
+ REFERENCES \`${SCHEMA}\`.\`${CompanyReg.table}\` (${col(CompanyReg, 'primaryKey')}) ON DELETE CASCADE,
89
+ CONSTRAINT FK_cd_department FOREIGN KEY (cd_department_uuid)
90
+ REFERENCES \`${SCHEMA}\`.\`${DepartmentReg.table}\` (${col(DepartmentReg, 'primaryKey')}) ON DELETE CASCADE
91
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`);
92
+
93
+ // company_branches — tenant-scoped branch catalog junction.
94
+ await query(`CREATE TABLE IF NOT EXISTS \`${SCHEMA}\`.\`${CompanyBranchReg.table}\` (
95
+ cb_company_uuid VARCHAR(36) NOT NULL,
96
+ cb_branch_uuid VARCHAR(36) NOT NULL,
97
+ PRIMARY KEY (cb_company_uuid, cb_branch_uuid),
98
+ CONSTRAINT FK_cb_company FOREIGN KEY (cb_company_uuid)
99
+ REFERENCES \`${SCHEMA}\`.\`${CompanyReg.table}\` (${col(CompanyReg, 'primaryKey')}) ON DELETE CASCADE,
100
+ CONSTRAINT FK_cb_branch FOREIGN KEY (cb_branch_uuid)
101
+ REFERENCES \`${SCHEMA}\`.\`${BranchReg.table}\` (${col(BranchReg, 'primaryKey')}) ON DELETE CASCADE
102
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`);
103
+ }
104
+
105
+ async function down({ query }) {
106
+ for (const t of [MembershipPostingReg.table, CompanyBranchReg.table, CompanyDepartmentReg.table, CompanyMembershipReg.table]) {
107
+ await query(`DROP TABLE IF EXISTS \`${SCHEMA}\`.\`${t}\``);
108
+ }
109
+ }
110
+
111
+ module.exports = { up, down };
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Framework identity migration — the identity_credential table (the login
3
+ * credential store) in `shield_identity`.
4
+ *
5
+ * Relocates the DDL from the retired Docker init-SQL file
6
+ * `infra/mariadb/init/30-identity-credential.sql` into a proper framework
7
+ * migration. `shield_identity` is a CORE security schema, NOT shield_app —
8
+ * login credentials are authentication data, structurally out of reach of the
9
+ * application accounts (ADR-0014). `shield_core` already holds DML
10
+ * (SELECT/INSERT/UPDATE/DELETE) on shield_identity (10-accounts-grants.sql),
11
+ * so the MariaDbCredentialResolver can SELECT this table with no new grant.
12
+ *
13
+ * ⛔ SECURITY: only the Argon2id ENCODED hash is stored — never a plaintext
14
+ * password. TOTP secrets are NOT stored here — the multi-device MFA model
15
+ * keeps each authenticator's secret in `shield_identity.mfa_device` (created
16
+ * by 1710000000005).
17
+ *
18
+ * This migration carries the CREATE TABLE only — the alice/bob/carol
19
+ * test-fixture seed rows that lived alongside this DDL in the init-SQL file
20
+ * are NOT reproduced here; they are re-homed as a dev-only seed in a later
21
+ * migration.
22
+ *
23
+ * NOT in the @astram/identity object registry (engine-internal auth table) —
24
+ * literal column names, no reflectObjectType, no @astram/identity import.
25
+ * Idempotent (CREATE TABLE IF NOT EXISTS).
26
+ */
27
+
28
+ const SCHEMA = 'shield_identity';
29
+
30
+ async function up({ query }) {
31
+ // identity_credential — one row per login principal. Resolved by
32
+ // ic_identity_hint (username/email) at login. ic_password_hash is the
33
+ // Argon2id encoded hash the engine verifies against. Whether the principal
34
+ // must also satisfy a TOTP second factor is derived from their CONFIRMED
35
+ // shield_identity.mfa_device rows (multi-device MFA), not a column here.
36
+ await query(
37
+ `CREATE TABLE IF NOT EXISTS \`${SCHEMA}\`.\`identity_credential\` (
38
+ \`ic_identity_hint\` VARCHAR(255) NOT NULL,
39
+ \`ic_password_hash\` VARCHAR(255) NOT NULL,
40
+ \`ic_principal_id\` VARCHAR(255) NOT NULL,
41
+ \`ic_tenant_id\` VARCHAR(255) NOT NULL,
42
+ \`ic_roles\` JSON NOT NULL,
43
+ \`ic_created_at_ms\` BIGINT NOT NULL,
44
+ \`ic_updated_at_ms\` BIGINT NOT NULL,
45
+ PRIMARY KEY (\`ic_identity_hint\`),
46
+ -- The login lookup is \`WHERE ic_identity_hint = ? LIMIT 1\`; the PK already
47
+ -- makes the hint the unique lookup key. A redundant UNIQUE index is stated
48
+ -- explicitly for the contract's "UNIQUE on ic_identity_hint" invariant.
49
+ UNIQUE KEY \`IDX_identity_credential_hint\` (\`ic_identity_hint\`)
50
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
51
+ );
52
+ }
53
+
54
+ async function down({ query }) {
55
+ await query(`DROP TABLE IF EXISTS \`${SCHEMA}\`.\`identity_credential\``);
56
+ }
57
+
58
+ module.exports = { up, down };
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Framework identity migration — durable SESSION + REFRESH-TOKEN tables
3
+ * (ADR-0013 hybrid storage, ADR-0015 lifetimes + rotation lineage + reuse
4
+ * detection) in `shield_identity`.
5
+ *
6
+ * Relocates the DDL from the retired Docker init-SQL file
7
+ * `infra/mariadb/init/20-session-tables.sql` into a proper framework
8
+ * migration. `shield_identity` is a CORE security schema, NOT shield_app —
9
+ * session/refresh state is authentication data, structurally out of reach of
10
+ * the application accounts (ADR-0014). `shield_core` already holds DML on
11
+ * shield_identity (10-accounts-grants.sql), so these tables are
12
+ * readable/writable by the Session engine's MariaDbSessionRecordStore with no
13
+ * new grant.
14
+ *
15
+ * ⛔ SECURITY: the refresh token itself is NEVER stored — only its keyed HASH
16
+ * (HMAC-SHA-256, hashed by the app before insert). rt_token_hash holds the hash.
17
+ *
18
+ * `session` is created FIRST — `refresh_token` carries FK_refresh_token_session
19
+ * referencing session(s_session_id), so the parent must exist before the child.
20
+ *
21
+ * NOT in the @astram/identity object registry (engine-internal auth tables) —
22
+ * literal column names, no reflectObjectType, no @astram/identity import.
23
+ * Idempotent (CREATE TABLE IF NOT EXISTS).
24
+ */
25
+
26
+ const SCHEMA = 'shield_identity';
27
+
28
+ async function up({ query }) {
29
+ // session — the authoritative session record (written at login; read/updated
30
+ // at refresh + revocation). s_absolute_expires_at_ms anchors the ADR-0015
31
+ // absolute cap (from login, never reset). Status ∈ active|rotated|revoked|expired.
32
+ await query(
33
+ `CREATE TABLE IF NOT EXISTS \`${SCHEMA}\`.\`session\` (
34
+ \`s_session_id\` CHAR(64) NOT NULL,
35
+ \`s_principal_id\` VARCHAR(255) NOT NULL,
36
+ \`s_tenant_id\` VARCHAR(255) NOT NULL,
37
+ \`s_status\` ENUM('active','rotated','revoked','expired') NOT NULL DEFAULT 'active',
38
+ \`s_issued_at_ms\` BIGINT NOT NULL,
39
+ \`s_absolute_expires_at_ms\` BIGINT NOT NULL,
40
+ PRIMARY KEY (\`s_session_id\`),
41
+ KEY \`IDX_session_principal\` (\`s_tenant_id\`, \`s_principal_id\`),
42
+ KEY \`IDX_session_status\` (\`s_status\`)
43
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
44
+ );
45
+
46
+ // refresh_token — one row per minted refresh token, chained by rotation lineage
47
+ // (ADR-0013/0015). rt_token_hash = the KEYED HASH (never the raw token).
48
+ // rt_lineage_id chains every rotation of one login; rt_prev_refresh_id points at
49
+ // the record this one superseded (NULL for the first). Reuse detection = a lookup
50
+ // by hash that finds a NON-active row → revoke the whole rt_lineage_id.
51
+ await query(
52
+ `CREATE TABLE IF NOT EXISTS \`${SCHEMA}\`.\`refresh_token\` (
53
+ \`rt_refresh_id\` CHAR(64) NOT NULL,
54
+ \`rt_session_id\` CHAR(64) NOT NULL,
55
+ \`rt_lineage_id\` CHAR(64) NOT NULL,
56
+ \`rt_prev_refresh_id\` CHAR(64) NULL,
57
+ \`rt_token_hash\` CHAR(64) NOT NULL,
58
+ \`rt_status\` ENUM('active','rotated','revoked','expired') NOT NULL DEFAULT 'active',
59
+ \`rt_issued_at_ms\` BIGINT NOT NULL,
60
+ \`rt_idle_expires_at_ms\` BIGINT NOT NULL,
61
+ PRIMARY KEY (\`rt_refresh_id\`),
62
+ -- Lookup by hash is the hot rotate path; unique so one hash resolves one record.
63
+ UNIQUE KEY \`IDX_refresh_token_hash\` (\`rt_token_hash\`),
64
+ KEY \`IDX_refresh_lineage\` (\`rt_lineage_id\`),
65
+ KEY \`IDX_refresh_session\` (\`rt_session_id\`),
66
+ CONSTRAINT \`FK_refresh_token_session\`
67
+ FOREIGN KEY (\`rt_session_id\`) REFERENCES \`${SCHEMA}\`.\`session\` (\`s_session_id\`)
68
+ ON DELETE CASCADE
69
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
70
+ );
71
+ }
72
+
73
+ async function down({ query }) {
74
+ // Child before parent — refresh_token FK-references session.
75
+ await query(`DROP TABLE IF EXISTS \`${SCHEMA}\`.\`refresh_token\``);
76
+ await query(`DROP TABLE IF EXISTS \`${SCHEMA}\`.\`session\``);
77
+ }
78
+
79
+ module.exports = { up, down };
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Framework identity migration — the MFA-device + forgot/reset + MFA-recovery
3
+ * tables in `shield_identity`: mfa_device, password_reset_token,
4
+ * mfa_recovery_code.
5
+ *
6
+ * Relocates the DDL from astram3 migrations 1720000000004
7
+ * (CreateMfaDeviceTable) and 1720000000003 (CreateCredentialSurfaceTables)
8
+ * into framework migrations. All three live in the shield_identity credential
9
+ * schema (ADR-0014) alongside identity_credential: device secrets,
10
+ * reset-token hashes, and recovery-code hashes are authentication data that
11
+ * belongs in the secure framework schema, not the app's org tables. The
12
+ * runtime shield_core account has DML there, so the consumer's port impls
13
+ * (MfaDeviceStore / ResetTokenStore / RecoveryCodeStore) INSERT/UPDATE/DELETE
14
+ * with no extra grant.
15
+ *
16
+ * Columns:
17
+ * - mfa_device: one row per enrolled authenticator device. md_secret is this
18
+ * device's OWN base32 TOTP secret (each device has a distinct secret; login
19
+ * matches ANY confirmed device via the consumer's OR-verifier).
20
+ * md_confirmed_at_ms NULL = pending (never satisfies login, self-pruned on
21
+ * the next begin); set = active. md_last_used_at_ms best-effort stamped
22
+ * when a code from this device passes. PK on md_id; index by principal for
23
+ * the per-user list/verify.
24
+ * - password_reset_token: framework hands a SHA-256 HASH of the raw token
25
+ * (never the raw token); TTL enforced on consume via prt_expires_at_ms;
26
+ * single-use marked by prt_consumed_at_ms. PK on the hash (one row per
27
+ * issued token); index by principal for invalidate-all.
28
+ * - mfa_recovery_code: one row per Argon2-hashed one-time recovery code; PK
29
+ * (principal, hash) so replaceAll wipes-then-inserts cleanly; single-use
30
+ * marked by mrc_consumed_at_ms.
31
+ *
32
+ * This clean DDL never created the retired single-secret `ic_totp_secret`
33
+ * column on identity_credential (the multi-device model stores every TOTP
34
+ * secret in mfa_device from the start here), so — unlike the astram3 source
35
+ * migration — there is no DROP COLUMN step to reproduce.
36
+ *
37
+ * NOT in the @astram/identity object registry (engine-internal auth tables) —
38
+ * literal column names, no reflectObjectType, no @astram/identity import.
39
+ * Idempotent (CREATE TABLE IF NOT EXISTS).
40
+ */
41
+
42
+ const SCHEMA = 'shield_identity';
43
+
44
+ async function up({ query }) {
45
+ await query(
46
+ `CREATE TABLE IF NOT EXISTS \`${SCHEMA}\`.\`mfa_device\` (
47
+ md_id VARCHAR(36) NOT NULL,
48
+ md_principal_id VARCHAR(255) NOT NULL,
49
+ md_name VARCHAR(120) NOT NULL,
50
+ md_secret VARCHAR(255) NOT NULL,
51
+ md_created_at_ms BIGINT NOT NULL,
52
+ md_last_used_at_ms BIGINT NULL,
53
+ md_confirmed_at_ms BIGINT NULL,
54
+ PRIMARY KEY (md_id),
55
+ INDEX idx_md_principal (md_principal_id)
56
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
57
+ );
58
+
59
+ await query(
60
+ `CREATE TABLE IF NOT EXISTS \`${SCHEMA}\`.\`password_reset_token\` (
61
+ prt_token_hash VARCHAR(255) NOT NULL,
62
+ prt_principal_id VARCHAR(255) NOT NULL,
63
+ prt_expires_at_ms BIGINT NOT NULL,
64
+ prt_consumed_at_ms BIGINT NULL,
65
+ prt_created_at_ms BIGINT NOT NULL,
66
+ PRIMARY KEY (prt_token_hash),
67
+ INDEX idx_prt_principal (prt_principal_id)
68
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
69
+ );
70
+
71
+ await query(
72
+ `CREATE TABLE IF NOT EXISTS \`${SCHEMA}\`.\`mfa_recovery_code\` (
73
+ mrc_principal_id VARCHAR(255) NOT NULL,
74
+ mrc_code_hash VARCHAR(255) NOT NULL,
75
+ mrc_consumed_at_ms BIGINT NULL,
76
+ mrc_created_at_ms BIGINT NOT NULL,
77
+ PRIMARY KEY (mrc_principal_id, mrc_code_hash)
78
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
79
+ );
80
+ }
81
+
82
+ async function down({ query }) {
83
+ for (const t of ['mfa_recovery_code', 'password_reset_token', 'mfa_device']) {
84
+ await query(`DROP TABLE IF EXISTS \`${SCHEMA}\`.\`${t}\``);
85
+ }
86
+ }
87
+
88
+ module.exports = { up, down };
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "@astram/identity",
3
+ "version": "0.1.0",
4
+ "license": "MIT",
5
+ "description": "Astram framework — the framework-owned identity + tenancy model. Hosts the identity @ObjectType classes (user, company, membership, posting), framework-authored identity migrations, and the identity resolver. Top of the dependency graph: depends on @astram/engine (for @ObjectType/@Field decorators) + @astram/guard / @astram/core (for the auth ports).",
6
+ "type": "commonjs",
7
+ "main": "dist/index.js",
8
+ "types": "dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "default": "./dist/index.js"
13
+ }
14
+ },
15
+ "files": [
16
+ "dist",
17
+ "migrations"
18
+ ],
19
+ "sideEffects": false,
20
+ "publishConfig": {
21
+ "access": "public"
22
+ },
23
+ "dependencies": {
24
+ "@astram/core": "1.0.0",
25
+ "@astram/engine": "0.1.0",
26
+ "@astram/guard": "0.3.0"
27
+ },
28
+ "peerDependencies": {
29
+ "@nestjs/common": "^11",
30
+ "@nestjs/core": "^11",
31
+ "reflect-metadata": "^0.2",
32
+ "rxjs": "^7"
33
+ },
34
+ "devDependencies": {
35
+ "@nestjs/common": "^11.0.0",
36
+ "@nestjs/core": "^11.0.0",
37
+ "@nestjs/testing": "^11.0.0",
38
+ "@types/node": "^22.0.0",
39
+ "reflect-metadata": "^0.2.2",
40
+ "rxjs": "^7.8.1",
41
+ "typescript": "^5.7.0",
42
+ "unplugin-swc": "^1.5.1",
43
+ "vitest": "^2.1.8"
44
+ },
45
+ "scripts": {
46
+ "build": "tsc -p tsconfig.build.json",
47
+ "lint": "eslint \"src/**/*.ts\" \"test/**/*.ts\"",
48
+ "test": "vitest run"
49
+ }
50
+ }