@scryan7371/sdr-security 0.1.1
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/README.md +72 -0
- package/dist/api/access-policy.d.ts +15 -0
- package/dist/api/access-policy.js +41 -0
- package/dist/api/access-policy.test.d.ts +1 -0
- package/dist/api/access-policy.test.js +67 -0
- package/dist/api/contracts.d.ts +46 -0
- package/dist/api/contracts.js +4 -0
- package/dist/api/index.d.ts +5 -0
- package/dist/api/index.js +21 -0
- package/dist/api/migrations/1700000000001-add-refresh-tokens.d.ts +9 -0
- package/dist/api/migrations/1700000000001-add-refresh-tokens.js +40 -0
- package/dist/api/migrations/1739490000000-add-google-subject-to-user.d.ts +5 -0
- package/dist/api/migrations/1739490000000-add-google-subject-to-user.js +14 -0
- package/dist/api/migrations/1739500000000-create-security-identity.d.ts +9 -0
- package/dist/api/migrations/1739500000000-create-security-identity.js +68 -0
- package/dist/api/migrations/1739510000000-create-security-roles.d.ts +9 -0
- package/dist/api/migrations/1739510000000-create-security-roles.js +95 -0
- package/dist/api/migrations/index.d.ts +6 -0
- package/dist/api/migrations/index.js +17 -0
- package/dist/api/roles.d.ts +3 -0
- package/dist/api/roles.js +29 -0
- package/dist/api/roles.test.d.ts +1 -0
- package/dist/api/roles.test.js +19 -0
- package/dist/api/validation.d.ts +3 -0
- package/dist/api/validation.js +9 -0
- package/dist/app/client.d.ts +52 -0
- package/dist/app/client.js +78 -0
- package/dist/app/index.d.ts +1 -0
- package/dist/app/index.js +17 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +38 -0
- package/package.json +34 -0
- package/src/api/access-policy.test.ts +103 -0
- package/src/api/access-policy.ts +68 -0
- package/src/api/contracts.ts +47 -0
- package/src/api/index.ts +5 -0
- package/src/api/migrations/1700000000001-add-refresh-tokens.ts +47 -0
- package/src/api/migrations/1739490000000-add-google-subject-to-user.ts +12 -0
- package/src/api/migrations/1739500000000-create-security-identity.ts +95 -0
- package/src/api/migrations/1739510000000-create-security-roles.ts +122 -0
- package/src/api/migrations/index.ts +18 -0
- package/src/api/roles.test.ts +20 -0
- package/src/api/roles.ts +25 -0
- package/src/api/validation.ts +7 -0
- package/src/app/client.ts +131 -0
- package/src/app/index.ts +1 -0
- package/src/index.ts +2 -0
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { AuthResponse, DebugCodeResponse, DebugTokenResponse, RoleCatalogResponse, RegisterResponse, UserRolesResponse } from "../api/contracts";
|
|
2
|
+
type FetchLike = typeof fetch;
|
|
3
|
+
export type SecurityClientOptions = {
|
|
4
|
+
baseUrl: string;
|
|
5
|
+
getAccessToken: () => string | null;
|
|
6
|
+
fetchImpl?: FetchLike;
|
|
7
|
+
};
|
|
8
|
+
export declare const createSecurityClient: (options: SecurityClientOptions) => {
|
|
9
|
+
register: (payload: {
|
|
10
|
+
email: string;
|
|
11
|
+
password: string;
|
|
12
|
+
firstName?: string;
|
|
13
|
+
lastName?: string;
|
|
14
|
+
}) => Promise<RegisterResponse>;
|
|
15
|
+
login: (payload: {
|
|
16
|
+
email: string;
|
|
17
|
+
password: string;
|
|
18
|
+
}) => Promise<AuthResponse>;
|
|
19
|
+
loginWithGoogle: (payload: {
|
|
20
|
+
idToken: string;
|
|
21
|
+
}) => Promise<AuthResponse>;
|
|
22
|
+
refresh: (payload: {
|
|
23
|
+
refreshToken: string;
|
|
24
|
+
}) => Promise<AuthResponse>;
|
|
25
|
+
revoke: (payload: {
|
|
26
|
+
refreshToken: string;
|
|
27
|
+
}) => Promise<{
|
|
28
|
+
success: true;
|
|
29
|
+
}>;
|
|
30
|
+
logout: (payload: {
|
|
31
|
+
refreshToken?: string;
|
|
32
|
+
}) => Promise<{
|
|
33
|
+
success: true;
|
|
34
|
+
}>;
|
|
35
|
+
requestEmailVerification: () => Promise<DebugTokenResponse>;
|
|
36
|
+
verifyEmail: (token: string) => Promise<{
|
|
37
|
+
success: true;
|
|
38
|
+
}>;
|
|
39
|
+
requestPhoneVerification: () => Promise<DebugCodeResponse>;
|
|
40
|
+
verifyPhone: (code: string) => Promise<{
|
|
41
|
+
success: true;
|
|
42
|
+
}>;
|
|
43
|
+
getMyRoles: () => Promise<UserRolesResponse>;
|
|
44
|
+
listRoles: () => Promise<RoleCatalogResponse>;
|
|
45
|
+
createRole: (payload: {
|
|
46
|
+
role: string;
|
|
47
|
+
description?: string | null;
|
|
48
|
+
}) => Promise<RoleCatalogResponse>;
|
|
49
|
+
getUserRoles: (userId: string) => Promise<UserRolesResponse>;
|
|
50
|
+
setUserRoles: (userId: string, roles: string[]) => Promise<UserRolesResponse>;
|
|
51
|
+
};
|
|
52
|
+
export {};
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.createSecurityClient = void 0;
|
|
4
|
+
const createSecurityClient = (options) => {
|
|
5
|
+
const fetchImpl = options.fetchImpl ?? fetch;
|
|
6
|
+
const request = async (path, init) => {
|
|
7
|
+
const token = options.getAccessToken();
|
|
8
|
+
const headers = {
|
|
9
|
+
"Content-Type": "application/json",
|
|
10
|
+
...(init?.headers ? init.headers : {}),
|
|
11
|
+
};
|
|
12
|
+
if (token) {
|
|
13
|
+
headers.Authorization = `Bearer ${token}`;
|
|
14
|
+
}
|
|
15
|
+
const response = await fetchImpl(`${options.baseUrl}${path}`, {
|
|
16
|
+
...init,
|
|
17
|
+
headers,
|
|
18
|
+
});
|
|
19
|
+
const text = await response.text();
|
|
20
|
+
const body = text ? JSON.parse(text) : {};
|
|
21
|
+
if (!response.ok) {
|
|
22
|
+
const message = typeof body?.message === "string"
|
|
23
|
+
? body.message
|
|
24
|
+
: `Request failed: ${response.status}`;
|
|
25
|
+
throw new Error(message);
|
|
26
|
+
}
|
|
27
|
+
return body;
|
|
28
|
+
};
|
|
29
|
+
return {
|
|
30
|
+
register: (payload) => request("/auth/register", {
|
|
31
|
+
method: "POST",
|
|
32
|
+
body: JSON.stringify(payload),
|
|
33
|
+
}),
|
|
34
|
+
login: (payload) => request("/auth/login", {
|
|
35
|
+
method: "POST",
|
|
36
|
+
body: JSON.stringify(payload),
|
|
37
|
+
}),
|
|
38
|
+
loginWithGoogle: (payload) => request("/auth/login/google", {
|
|
39
|
+
method: "POST",
|
|
40
|
+
body: JSON.stringify(payload),
|
|
41
|
+
}),
|
|
42
|
+
refresh: (payload) => request("/auth/refresh", {
|
|
43
|
+
method: "POST",
|
|
44
|
+
body: JSON.stringify(payload),
|
|
45
|
+
}),
|
|
46
|
+
revoke: (payload) => request("/auth/revoke", {
|
|
47
|
+
method: "POST",
|
|
48
|
+
body: JSON.stringify(payload),
|
|
49
|
+
}),
|
|
50
|
+
logout: (payload) => request("/auth/logout", {
|
|
51
|
+
method: "POST",
|
|
52
|
+
body: JSON.stringify(payload),
|
|
53
|
+
}),
|
|
54
|
+
requestEmailVerification: () => request("/auth/request-email-verification", {
|
|
55
|
+
method: "POST",
|
|
56
|
+
}),
|
|
57
|
+
verifyEmail: (token) => request(`/auth/verify-email?token=${token}`),
|
|
58
|
+
requestPhoneVerification: () => request("/auth/request-phone-verification", {
|
|
59
|
+
method: "POST",
|
|
60
|
+
}),
|
|
61
|
+
verifyPhone: (code) => request("/auth/verify-phone", {
|
|
62
|
+
method: "POST",
|
|
63
|
+
body: JSON.stringify({ code }),
|
|
64
|
+
}),
|
|
65
|
+
getMyRoles: () => request("/auth/me/roles"),
|
|
66
|
+
listRoles: () => request("/admin/roles"),
|
|
67
|
+
createRole: (payload) => request("/admin/roles", {
|
|
68
|
+
method: "POST",
|
|
69
|
+
body: JSON.stringify(payload),
|
|
70
|
+
}),
|
|
71
|
+
getUserRoles: (userId) => request(`/admin/users/${userId}/roles`),
|
|
72
|
+
setUserRoles: (userId, roles) => request(`/admin/users/${userId}/roles`, {
|
|
73
|
+
method: "PUT",
|
|
74
|
+
body: JSON.stringify({ roles }),
|
|
75
|
+
}),
|
|
76
|
+
};
|
|
77
|
+
};
|
|
78
|
+
exports.createSecurityClient = createSecurityClient;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./client";
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
|
14
|
+
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
|
|
15
|
+
};
|
|
16
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
|
+
__exportStar(require("./client"), exports);
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.app = exports.api = void 0;
|
|
37
|
+
exports.api = __importStar(require("./api"));
|
|
38
|
+
exports.app = __importStar(require("./app"));
|
package/package.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@scryan7371/sdr-security",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Reusable auth/security capability for API and app clients.",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": "./dist/index.js"
|
|
8
|
+
},
|
|
9
|
+
"types": "dist/index.d.ts",
|
|
10
|
+
"publishConfig": {
|
|
11
|
+
"registry": "https://registry.npmjs.org",
|
|
12
|
+
"access": "public"
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"dist",
|
|
16
|
+
"src"
|
|
17
|
+
],
|
|
18
|
+
"scripts": {
|
|
19
|
+
"build": "tsc -p tsconfig.json",
|
|
20
|
+
"clean": "rm -rf dist",
|
|
21
|
+
"prepublishOnly": "npm run clean && npm run build",
|
|
22
|
+
"test": "vitest run",
|
|
23
|
+
"test:cov": "vitest run --coverage"
|
|
24
|
+
},
|
|
25
|
+
"dependencies": {
|
|
26
|
+
"@babel/runtime": "7.28.6"
|
|
27
|
+
},
|
|
28
|
+
"devDependencies": {
|
|
29
|
+
"@types/node": "^25.2.3",
|
|
30
|
+
"@vitest/coverage-v8": "^4.0.18",
|
|
31
|
+
"typescript": "5.9.3",
|
|
32
|
+
"vitest": "4.0.18"
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
accessBlockReasonToMessage,
|
|
4
|
+
getAuthBlockReason,
|
|
5
|
+
} from "./access-policy";
|
|
6
|
+
|
|
7
|
+
const baseUser = {
|
|
8
|
+
isActive: true,
|
|
9
|
+
emailVerifiedAt: new Date(),
|
|
10
|
+
phoneVerifiedAt: null,
|
|
11
|
+
adminApprovedAt: new Date(),
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
describe("getAuthBlockReason", () => {
|
|
15
|
+
it("returns null for user meeting default requirements", () => {
|
|
16
|
+
expect(getAuthBlockReason(baseUser)).toBeNull();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("blocks deactivated users first", () => {
|
|
20
|
+
expect(
|
|
21
|
+
getAuthBlockReason({
|
|
22
|
+
...baseUser,
|
|
23
|
+
isActive: false,
|
|
24
|
+
emailVerifiedAt: null,
|
|
25
|
+
adminApprovedAt: null,
|
|
26
|
+
}),
|
|
27
|
+
).toBe("ACCOUNT_DEACTIVATED");
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("blocks when email verification is required and missing", () => {
|
|
31
|
+
expect(
|
|
32
|
+
getAuthBlockReason({
|
|
33
|
+
...baseUser,
|
|
34
|
+
emailVerifiedAt: null,
|
|
35
|
+
}),
|
|
36
|
+
).toBe("EMAIL_VERIFICATION_REQUIRED");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("does not block missing phone when phone verification is disabled", () => {
|
|
40
|
+
expect(
|
|
41
|
+
getAuthBlockReason({
|
|
42
|
+
...baseUser,
|
|
43
|
+
phoneVerifiedAt: null,
|
|
44
|
+
}),
|
|
45
|
+
).toBeNull();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("blocks missing phone when phone verification is enabled", () => {
|
|
49
|
+
expect(
|
|
50
|
+
getAuthBlockReason(
|
|
51
|
+
{
|
|
52
|
+
...baseUser,
|
|
53
|
+
phoneVerifiedAt: null,
|
|
54
|
+
},
|
|
55
|
+
{ requirePhoneVerification: true },
|
|
56
|
+
),
|
|
57
|
+
).toBe("PHONE_VERIFICATION_REQUIRED");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("blocks when admin approval is required and missing", () => {
|
|
61
|
+
expect(
|
|
62
|
+
getAuthBlockReason({
|
|
63
|
+
...baseUser,
|
|
64
|
+
adminApprovedAt: null,
|
|
65
|
+
}),
|
|
66
|
+
).toBe("ADMIN_APPROVAL_REQUIRED");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("respects disabled flags", () => {
|
|
70
|
+
expect(
|
|
71
|
+
getAuthBlockReason(
|
|
72
|
+
{
|
|
73
|
+
...baseUser,
|
|
74
|
+
isActive: false,
|
|
75
|
+
emailVerifiedAt: null,
|
|
76
|
+
adminApprovedAt: null,
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
requireActive: false,
|
|
80
|
+
requireEmailVerification: false,
|
|
81
|
+
requireAdminApproval: false,
|
|
82
|
+
},
|
|
83
|
+
),
|
|
84
|
+
).toBeNull();
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe("accessBlockReasonToMessage", () => {
|
|
89
|
+
it("maps each reason to expected message", () => {
|
|
90
|
+
expect(accessBlockReasonToMessage("EMAIL_VERIFICATION_REQUIRED")).toBe(
|
|
91
|
+
"Email verification required",
|
|
92
|
+
);
|
|
93
|
+
expect(accessBlockReasonToMessage("PHONE_VERIFICATION_REQUIRED")).toBe(
|
|
94
|
+
"Phone verification required",
|
|
95
|
+
);
|
|
96
|
+
expect(accessBlockReasonToMessage("ADMIN_APPROVAL_REQUIRED")).toBe(
|
|
97
|
+
"Admin approval required",
|
|
98
|
+
);
|
|
99
|
+
expect(accessBlockReasonToMessage("ACCOUNT_DEACTIVATED")).toBe(
|
|
100
|
+
"Account deactivated",
|
|
101
|
+
);
|
|
102
|
+
});
|
|
103
|
+
});
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
export type AccessPolicyUser = {
|
|
2
|
+
isActive: boolean;
|
|
3
|
+
emailVerifiedAt: string | Date | null;
|
|
4
|
+
phoneVerifiedAt?: string | Date | null;
|
|
5
|
+
adminApprovedAt: string | Date | null;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
export type AccessPolicyOptions = {
|
|
9
|
+
requireEmailVerification?: boolean;
|
|
10
|
+
requirePhoneVerification?: boolean;
|
|
11
|
+
requireAdminApproval?: boolean;
|
|
12
|
+
requireActive?: boolean;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export type AccessBlockReason =
|
|
16
|
+
| "EMAIL_VERIFICATION_REQUIRED"
|
|
17
|
+
| "PHONE_VERIFICATION_REQUIRED"
|
|
18
|
+
| "ADMIN_APPROVAL_REQUIRED"
|
|
19
|
+
| "ACCOUNT_DEACTIVATED";
|
|
20
|
+
|
|
21
|
+
const DEFAULT_OPTIONS: Required<AccessPolicyOptions> = {
|
|
22
|
+
requireEmailVerification: true,
|
|
23
|
+
requirePhoneVerification: false,
|
|
24
|
+
requireAdminApproval: true,
|
|
25
|
+
requireActive: true,
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export const getAuthBlockReason = (
|
|
29
|
+
user: AccessPolicyUser,
|
|
30
|
+
options: AccessPolicyOptions = {},
|
|
31
|
+
): AccessBlockReason | null => {
|
|
32
|
+
const effective = { ...DEFAULT_OPTIONS, ...options };
|
|
33
|
+
|
|
34
|
+
if (effective.requireActive && !user.isActive) {
|
|
35
|
+
return "ACCOUNT_DEACTIVATED";
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (effective.requireEmailVerification && !user.emailVerifiedAt) {
|
|
39
|
+
return "EMAIL_VERIFICATION_REQUIRED";
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (effective.requirePhoneVerification && !user.phoneVerifiedAt) {
|
|
43
|
+
return "PHONE_VERIFICATION_REQUIRED";
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (effective.requireAdminApproval && !user.adminApprovedAt) {
|
|
47
|
+
return "ADMIN_APPROVAL_REQUIRED";
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return null;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export const accessBlockReasonToMessage = (
|
|
54
|
+
reason: AccessBlockReason,
|
|
55
|
+
): string => {
|
|
56
|
+
switch (reason) {
|
|
57
|
+
case "EMAIL_VERIFICATION_REQUIRED":
|
|
58
|
+
return "Email verification required";
|
|
59
|
+
case "PHONE_VERIFICATION_REQUIRED":
|
|
60
|
+
return "Phone verification required";
|
|
61
|
+
case "ADMIN_APPROVAL_REQUIRED":
|
|
62
|
+
return "Admin approval required";
|
|
63
|
+
case "ACCOUNT_DEACTIVATED":
|
|
64
|
+
return "Account deactivated";
|
|
65
|
+
default:
|
|
66
|
+
return "Unauthorized";
|
|
67
|
+
}
|
|
68
|
+
};
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
export const ADMIN_ROLE = "ADMIN";
|
|
2
|
+
export type UserRole = string;
|
|
3
|
+
|
|
4
|
+
export type SafeUser = {
|
|
5
|
+
id: string;
|
|
6
|
+
email: string;
|
|
7
|
+
firstName: string | null;
|
|
8
|
+
lastName: string | null;
|
|
9
|
+
phone: string | null;
|
|
10
|
+
roles: UserRole[];
|
|
11
|
+
emailVerifiedAt: string | Date | null;
|
|
12
|
+
phoneVerifiedAt: string | Date | null;
|
|
13
|
+
adminApprovedAt: string | Date | null;
|
|
14
|
+
isActive: boolean;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export type UserRolesResponse = {
|
|
18
|
+
userId: string;
|
|
19
|
+
roles: UserRole[];
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export type RoleDefinition = {
|
|
23
|
+
role: UserRole;
|
|
24
|
+
description: string | null;
|
|
25
|
+
isSystem: boolean;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export type RoleCatalogResponse = {
|
|
29
|
+
roles: RoleDefinition[];
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export type AuthResponse = {
|
|
33
|
+
accessToken: string;
|
|
34
|
+
accessTokenExpiresIn: string;
|
|
35
|
+
refreshToken: string;
|
|
36
|
+
refreshTokenExpiresAt: string | Date;
|
|
37
|
+
user: SafeUser;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export type RegisterResponse = {
|
|
41
|
+
success: true;
|
|
42
|
+
user: SafeUser;
|
|
43
|
+
debugToken?: string;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export type DebugTokenResponse = { success: true; debugToken?: string };
|
|
47
|
+
export type DebugCodeResponse = { success: true; debugCode?: string };
|
package/src/api/index.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
export class AddRefreshTokens1700000000001 {
|
|
2
|
+
name = "AddRefreshTokens1700000000001";
|
|
3
|
+
|
|
4
|
+
async up(queryRunner: {
|
|
5
|
+
query: (sql: string) => Promise<unknown>;
|
|
6
|
+
}): Promise<void> {
|
|
7
|
+
const userTableRef = getUserTableReference();
|
|
8
|
+
|
|
9
|
+
await queryRunner.query(`
|
|
10
|
+
CREATE TABLE "refresh_token" (
|
|
11
|
+
"id" varchar PRIMARY KEY NOT NULL,
|
|
12
|
+
"token_hash" varchar NOT NULL,
|
|
13
|
+
"expires_at" timestamptz NOT NULL,
|
|
14
|
+
"revoked_at" timestamptz,
|
|
15
|
+
"userId" varchar,
|
|
16
|
+
"created_at" timestamptz NOT NULL DEFAULT (CURRENT_TIMESTAMP),
|
|
17
|
+
CONSTRAINT "FK_refresh_token_user" FOREIGN KEY ("userId") REFERENCES ${userTableRef} ("id") ON DELETE CASCADE ON UPDATE NO ACTION
|
|
18
|
+
)
|
|
19
|
+
`);
|
|
20
|
+
await queryRunner.query(
|
|
21
|
+
`CREATE INDEX "IDX_refresh_token_user" ON "refresh_token" ("userId")`,
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async down(queryRunner: {
|
|
26
|
+
query: (sql: string) => Promise<unknown>;
|
|
27
|
+
}): Promise<void> {
|
|
28
|
+
await queryRunner.query(`DROP INDEX "IDX_refresh_token_user"`);
|
|
29
|
+
await queryRunner.query(`DROP TABLE "refresh_token"`);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const getUserTableReference = () => {
|
|
34
|
+
const table = getSafeIdentifier(process.env.USER_TABLE, "app_user");
|
|
35
|
+
const schema = process.env.USER_TABLE_SCHEMA
|
|
36
|
+
? getSafeIdentifier(process.env.USER_TABLE_SCHEMA, "")
|
|
37
|
+
: "";
|
|
38
|
+
return schema ? `"${schema}"."${table}"` : `"${table}"`;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const getSafeIdentifier = (value: string | undefined, fallback: string) => {
|
|
42
|
+
const resolved = value?.trim() || fallback;
|
|
43
|
+
if (!resolved || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(resolved)) {
|
|
44
|
+
throw new Error(`Invalid SQL identifier: ${resolved}`);
|
|
45
|
+
}
|
|
46
|
+
return resolved;
|
|
47
|
+
};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export class AddGoogleSubjectToUser1739490000000 {
|
|
2
|
+
name = "AddGoogleSubjectToUser1739490000000";
|
|
3
|
+
|
|
4
|
+
// Legacy migration retained for backward compatibility with existing migration history.
|
|
5
|
+
async up(): Promise<void> {
|
|
6
|
+
return;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
async down(): Promise<void> {
|
|
10
|
+
return;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
export class CreateSecurityIdentity1739500000000 {
|
|
2
|
+
name = "CreateSecurityIdentity1739500000000";
|
|
3
|
+
|
|
4
|
+
async up(queryRunner: {
|
|
5
|
+
query: (sql: string, params?: unknown[]) => Promise<unknown>;
|
|
6
|
+
}): Promise<void> {
|
|
7
|
+
const userTable = getSafeIdentifier(process.env.USER_TABLE, "app_user");
|
|
8
|
+
const userSchema = getSafeIdentifier(
|
|
9
|
+
process.env.USER_TABLE_SCHEMA,
|
|
10
|
+
"public",
|
|
11
|
+
);
|
|
12
|
+
const userTableRef = `"${userSchema}"."${userTable}"`;
|
|
13
|
+
|
|
14
|
+
await queryRunner.query(`CREATE EXTENSION IF NOT EXISTS "uuid-ossp"`);
|
|
15
|
+
|
|
16
|
+
await queryRunner.query(`
|
|
17
|
+
CREATE TABLE IF NOT EXISTS "security_identity" (
|
|
18
|
+
"id" uuid PRIMARY KEY NOT NULL DEFAULT uuid_generate_v4(),
|
|
19
|
+
"user_id" varchar NOT NULL,
|
|
20
|
+
"provider" varchar NOT NULL,
|
|
21
|
+
"provider_subject" varchar NOT NULL,
|
|
22
|
+
"created_at" timestamptz NOT NULL DEFAULT now(),
|
|
23
|
+
"updated_at" timestamptz NOT NULL DEFAULT now(),
|
|
24
|
+
CONSTRAINT "FK_security_identity_user_id" FOREIGN KEY ("user_id") REFERENCES ${userTableRef} ("id") ON DELETE CASCADE
|
|
25
|
+
)
|
|
26
|
+
`);
|
|
27
|
+
|
|
28
|
+
await queryRunner.query(
|
|
29
|
+
`CREATE UNIQUE INDEX IF NOT EXISTS "IDX_security_identity_provider_subject" ON "security_identity" ("provider", "provider_subject")`,
|
|
30
|
+
);
|
|
31
|
+
await queryRunner.query(
|
|
32
|
+
`CREATE UNIQUE INDEX IF NOT EXISTS "IDX_security_identity_user_provider" ON "security_identity" ("user_id", "provider")`,
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
const hasGoogleSubjectColumn = (await queryRunner.query(
|
|
36
|
+
`
|
|
37
|
+
SELECT 1
|
|
38
|
+
FROM information_schema.columns
|
|
39
|
+
WHERE table_schema = $1
|
|
40
|
+
AND table_name = $2
|
|
41
|
+
AND column_name = 'google_subject'
|
|
42
|
+
LIMIT 1
|
|
43
|
+
`,
|
|
44
|
+
[userSchema, userTable],
|
|
45
|
+
)) as Array<{ "?column?": number }>;
|
|
46
|
+
|
|
47
|
+
if (hasGoogleSubjectColumn.length > 0) {
|
|
48
|
+
await queryRunner.query(`
|
|
49
|
+
INSERT INTO "security_identity" (
|
|
50
|
+
"user_id",
|
|
51
|
+
"provider",
|
|
52
|
+
"provider_subject",
|
|
53
|
+
"created_at",
|
|
54
|
+
"updated_at"
|
|
55
|
+
)
|
|
56
|
+
SELECT
|
|
57
|
+
"id",
|
|
58
|
+
'google',
|
|
59
|
+
"google_subject",
|
|
60
|
+
now(),
|
|
61
|
+
now()
|
|
62
|
+
FROM ${userTableRef}
|
|
63
|
+
WHERE "google_subject" IS NOT NULL
|
|
64
|
+
ON CONFLICT ("provider", "provider_subject") DO NOTHING
|
|
65
|
+
`);
|
|
66
|
+
|
|
67
|
+
await queryRunner.query(
|
|
68
|
+
`DROP INDEX IF EXISTS "IDX_app_user_google_subject"`,
|
|
69
|
+
);
|
|
70
|
+
await queryRunner.query(
|
|
71
|
+
`ALTER TABLE ${userTableRef} DROP COLUMN IF EXISTS "google_subject"`,
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async down(queryRunner: {
|
|
77
|
+
query: (sql: string) => Promise<unknown>;
|
|
78
|
+
}): Promise<void> {
|
|
79
|
+
await queryRunner.query(
|
|
80
|
+
`DROP INDEX IF EXISTS "IDX_security_identity_user_provider"`,
|
|
81
|
+
);
|
|
82
|
+
await queryRunner.query(
|
|
83
|
+
`DROP INDEX IF EXISTS "IDX_security_identity_provider_subject"`,
|
|
84
|
+
);
|
|
85
|
+
await queryRunner.query(`DROP TABLE IF EXISTS "security_identity"`);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const getSafeIdentifier = (value: string | undefined, fallback: string) => {
|
|
90
|
+
const resolved = value?.trim() || fallback;
|
|
91
|
+
if (!resolved || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(resolved)) {
|
|
92
|
+
throw new Error(`Invalid SQL identifier: ${resolved}`);
|
|
93
|
+
}
|
|
94
|
+
return resolved;
|
|
95
|
+
};
|