prisma-extension-saltids 1.0.2

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 pond918
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,102 @@
1
+ # prisma-extension-saltids
2
+
3
+ **SaltIDs** is a Prisma 5+ extension that transparently transforms `Auto-Increment Int ID` + `Random Salt` into a single `Int` Public ID at the application layer. It provides an ID obfuscation solution with **zero schema overhead** through intelligent query interception.
4
+
5
+ * **App Layer**: Sees `123312` (Number).
6
+ * **DB Layer**: Stores `id: 312` and `idSalt: 123`.
7
+
8
+ This prevents **ID enumeration attacks** (scraping) while maintaining **primary key index performance**.
9
+
10
+ ## Core Advantages
11
+
12
+ * 🛡️ **Enumeration Protection**: Even though IDs are auto-incrementing, the external world sees randomly jumping numbers.
13
+ * ⚡ **Zero Performance Cost**: Leverages the original primary key index for queries, requiring no additional indexes.
14
+ * 🪄 **Fully Transparent**:
15
+ * Reading `user.id` automatically returns SaltID.
16
+ * Writing `userId: SaltID` automatically unpacks and stores.
17
+ * Querying `findUnique({ where: { id: SaltID } })` is automatically handled.
18
+ * 🙈 **Clean Output**: The `idSalt` field is hidden from `JSON.stringify` and loops.
19
+
20
+ ## Installation
21
+
22
+ ```bash
23
+ npm install prisma-extension-saltids
24
+ ```
25
+
26
+ ## Schema Preparation
27
+
28
+ Simply use the `Int` type and ensure there's a `idSalt` field. **No** additional `@@unique` indexes are required.
29
+
30
+ ```prisma
31
+ model User {
32
+ // 1. Physical Primary Key (Int)
33
+ id Int @id @default(autoincrement())
34
+ // 2. Random Salt (Int)
35
+ idSalt Int?
36
+
37
+ posts Post[]
38
+ }
39
+
40
+ model Post {
41
+ postPk Int @id @default(autoincrement())
42
+ postPkSalt Int?
43
+
44
+ title String
45
+
46
+ // 3. Relation Field Naming Convention (xxx + xxxSalt)
47
+ authorId Int
48
+ authorIdSalt Int?
49
+ author User @relation(fields: [authorId], references: [id])
50
+
51
+ @@index([authorId])
52
+ }
53
+ ```
54
+
55
+ ## Register Extension
56
+
57
+ ```typescript
58
+ import { PrismaClient } from '@prisma/client';
59
+ import { saltIdsExtension } from 'prisma-extension-saltids';
60
+
61
+ const prisma = new PrismaClient().$extends(
62
+ saltIdsExtension({
63
+ saltLength: 3, // Default: 3 digits
64
+ saltSuffix: 'Salt', // Default suffix for salt fields
65
+ })
66
+ );
67
+ ```
68
+
69
+ ## Usage Example
70
+
71
+ ```typescript
72
+ async function main() {
73
+ // 1. Create (Transparent unpacking)
74
+ const user = await prisma.user.create({
75
+ data: { name: 'Geek' }
76
+ });
77
+
78
+ // 2. Read (Transparent composition)
79
+ console.log(user.id); // 123312 (Number)
80
+ console.log(JSON.stringify(user)); // {"id":123312, "name":"Geek"}
81
+
82
+ // 3. Query (Automatically downgraded to findFirst)
83
+ // Although you're calling findUnique, because the query condition becomes { id, idSalt }
84
+ // the plugin automatically converts it to an efficient findFirst query, leveraging the id index.
85
+ const found = await prisma.user.findUnique({
86
+ where: { id: user.id }
87
+ });
88
+
89
+ // 4. Relations (Transparent)
90
+ // Automatically unpacks authorId (SaltID) -> authorId + authorIdSalt
91
+ await prisma.post.create({
92
+ data: {
93
+ title: 'Hello SaltIDs',
94
+ authorId: user.id
95
+ }
96
+ });
97
+ }
98
+ ```
99
+
100
+ ## License
101
+
102
+ MIT
@@ -0,0 +1,12 @@
1
+ import { SaltIdsOptions } from "./types";
2
+ export { SaltIdsOptions };
3
+ export declare const saltIdsExtension: (options?: SaltIdsOptions) => (client: any) => {
4
+ $extends: {
5
+ extArgs: {
6
+ result: {};
7
+ model: {};
8
+ query: {};
9
+ client: {};
10
+ };
11
+ };
12
+ };
package/dist/index.js ADDED
@@ -0,0 +1,78 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.saltIdsExtension = void 0;
4
+ const client_1 = require("@prisma/client");
5
+ const logic_1 = require("./logic");
6
+ const utils_1 = require("./utils");
7
+ const saltIdsExtension = (options) => {
8
+ const config = {
9
+ saltLength: options?.saltLength ?? 4,
10
+ saltSuffix: options?.saltSuffix ?? "Salt",
11
+ };
12
+ const registry = new utils_1.ModelRegistry();
13
+ return client_1.Prisma.defineExtension((client) => {
14
+ return client.$extends({
15
+ name: "prisma-extension-saltids",
16
+ query: {
17
+ $allModels: {
18
+ async $allOperations({ model, operation, args, query }) {
19
+ // Ensure registry is initialized
20
+ // @ts-ignore
21
+ if (client_1.Prisma.dmmf) {
22
+ // @ts-ignore
23
+ registry.init(client_1.Prisma.dmmf, config.saltSuffix);
24
+ }
25
+ // ------------------------------------------------
26
+ // 1. 输入参数转换 (Input Transformation)
27
+ // ------------------------------------------------
28
+ let didTransformId = false;
29
+ // args 包含 where, data, select, include 等
30
+ // 我们直接对 args 进行变换,递归中会根据 Key 匹配字段
31
+ if (args) {
32
+ const res = (0, logic_1.deepTransformInput)(args, model, registry, config);
33
+ didTransformId = res.didTransformId;
34
+ }
35
+ // ------------------------------------------------
36
+ // 2. 自动生成 Salt (Auto Generate Salt)
37
+ // ------------------------------------------------
38
+ if (operation === "create" || operation === "createMany") {
39
+ if (args.data)
40
+ (0, logic_1.deepInjectSalt)(args.data, model, registry, config, false);
41
+ }
42
+ else if (operation === "update" || operation === "updateMany") {
43
+ if (args.data)
44
+ (0, logic_1.deepInjectSalt)(args.data, model, registry, config, true);
45
+ }
46
+ else if (operation === "upsert") {
47
+ if (args.create)
48
+ (0, logic_1.deepInjectSalt)(args.create, model, registry, config, false);
49
+ if (args.update)
50
+ (0, logic_1.deepInjectSalt)(args.update, model, registry, config, true);
51
+ }
52
+ // ------------------------------------------------
53
+ // 3. 执行查询 (含自动降级逻辑)
54
+ // ------------------------------------------------
55
+ let result;
56
+ // 场景:用户调用 findUnique,但我们注入了 salt。
57
+ // 此时 args.where 包含 { id, salt },这在没有联合唯一索引时会导致 Prisma 报错。
58
+ // 解决:拦截此操作,手动改为调用 findFirst。
59
+ if (operation === "findUnique" && didTransformId) {
60
+ result = await client[model].findFirst(args);
61
+ }
62
+ else {
63
+ result = await query(args);
64
+ }
65
+ // ------------------------------------------------
66
+ // 4. 结果劫持 (隐藏 Salt,暴露 SaltID)
67
+ // ------------------------------------------------
68
+ if (result) {
69
+ (0, logic_1.deepHijackResult)(result, config);
70
+ }
71
+ return result;
72
+ },
73
+ },
74
+ },
75
+ });
76
+ });
77
+ };
78
+ exports.saltIdsExtension = saltIdsExtension;
@@ -0,0 +1,7 @@
1
+ import { ModelRegistry } from "./utils";
2
+ import { SaltIdsOptions } from "./types";
3
+ export declare function deepTransformInput(obj: any, modelName: string, registry: ModelRegistry, options: Required<SaltIdsOptions>): {
4
+ didTransformId: boolean;
5
+ };
6
+ export declare function deepInjectSalt(data: any, modelName: string, registry: ModelRegistry, options: Required<SaltIdsOptions>, skipRootInjection?: boolean): void;
7
+ export declare function deepHijackResult(data: any, options: Required<SaltIdsOptions>): void;
package/dist/logic.js ADDED
@@ -0,0 +1,164 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.deepTransformInput = deepTransformInput;
4
+ exports.deepInjectSalt = deepInjectSalt;
5
+ exports.deepHijackResult = deepHijackResult;
6
+ const utils_1 = require("./utils");
7
+ // -----------------------------------------------------------------------------
8
+ // 1. 输入参数转换 (Input Transformation)
9
+ // -----------------------------------------------------------------------------
10
+ // 逻辑:将 SaltID (Number) 拆解为 Real ID (Number) + Salt (Number)
11
+ // 动态匹配:根据 ModelRegistry 查找当前模型中成对出现的字段 (xxx, xxxSalt)
12
+ function deepTransformInput(obj, modelName, registry, options) {
13
+ let didTransformId = false;
14
+ if (!obj || typeof obj !== "object")
15
+ return { didTransformId };
16
+ if (!modelName)
17
+ return { didTransformId }; // Safety check
18
+ if (Array.isArray(obj)) {
19
+ for (const item of obj) {
20
+ const res = deepTransformInput(item, modelName, registry, options);
21
+ if (res.didTransformId)
22
+ didTransformId = true;
23
+ }
24
+ return { didTransformId };
25
+ }
26
+ // 获取当前模型的 Salt 字段定义
27
+ const saltFields = registry.getSaltFields(modelName);
28
+ for (const key of Object.keys(obj)) {
29
+ const val = obj[key];
30
+ // Case A: 假如 key 是某个需要混淆的字段 (base field)
31
+ const saltFieldDef = saltFields.find((f) => f.base === key);
32
+ if (saltFieldDef && typeof val === "number") {
33
+ // 检查是否看起来像 SaltID
34
+ if (utils_1.SaltIdsHelper.isPotentialSaltId(val, options.saltLength)) {
35
+ const { id, salt } = utils_1.SaltIdsHelper.decode(val, options.saltLength);
36
+ obj[key] = id; // 替换为真实 ID
37
+ // 自动注入 Salt (如果缺失)
38
+ if (obj[saltFieldDef.salt] === undefined) {
39
+ obj[saltFieldDef.salt] = salt;
40
+ }
41
+ // 标记:如果转换了字段,可能影响 findUnique
42
+ // 简单启发式:只要转换了任何字段,都标记一下
43
+ didTransformId = true;
44
+ }
45
+ }
46
+ // Case B: 递归处理对象 (可能是关系嵌套,也可能是操作符)
47
+ else if (typeof val === "object" && val !== null) {
48
+ // Check if `key` is a known relation
49
+ const relation = registry.getRelation(modelName, key);
50
+ if (relation) {
51
+ // 如果是关系字段,切换上下文到目标模型
52
+ const res = deepTransformInput(val, relation.type, registry, options);
53
+ if (res.didTransformId)
54
+ didTransformId = true;
55
+ }
56
+ else {
57
+ // 如果不是关系字段 (如 AND, OR, create, where 等操作符),保持当前模型上下文
58
+ const res = deepTransformInput(val, modelName, registry, options);
59
+ if (res.didTransformId)
60
+ didTransformId = true;
61
+ }
62
+ }
63
+ }
64
+ return { didTransformId };
65
+ }
66
+ // -----------------------------------------------------------------------------
67
+ // 1.5. 自动注入 Salt (Auto Inject Salt)
68
+ // -----------------------------------------------------------------------------
69
+ // 逻辑:在 create 场景下,为所有缺失 Salt 的字段自动生成随机 Salt
70
+ function deepInjectSalt(data, modelName, registry, options, skipRootInjection = false) {
71
+ if (!data || typeof data !== "object")
72
+ return;
73
+ if (!modelName)
74
+ return;
75
+ if (Array.isArray(data)) {
76
+ data.forEach((item) => deepInjectSalt(item, modelName, registry, options, skipRootInjection));
77
+ return;
78
+ }
79
+ // 1. 注入当前层级的 Salt
80
+ if (!skipRootInjection) {
81
+ const saltFields = registry.getSaltFields(modelName);
82
+ for (const { base, salt } of saltFields) {
83
+ // 策略:如果 Salt 字段缺失,则生成
84
+ // 不管 base 是否存在 (Base 可能是 autoincrement,所以不存在 data 中)
85
+ if (data[salt] === undefined) {
86
+ data[salt] = utils_1.SaltIdsHelper.generateSalt(options.saltLength);
87
+ }
88
+ }
89
+ }
90
+ // 2. 递归查找嵌套写入
91
+ for (const key of Object.keys(data)) {
92
+ const val = data[key];
93
+ if (typeof val === "object" && val !== null) {
94
+ // Check for relation
95
+ const relation = registry.getRelation(modelName, key);
96
+ const targetModel = relation ? relation.type : undefined;
97
+ if (targetModel) {
98
+ // Prisma Nested Writes Keywords
99
+ const nestedOps = ["create", "update", "upsert", "connectOrCreate"];
100
+ // Handle simple nested create (e.g. { posts: { create: ... } })
101
+ if (val.create) {
102
+ deepInjectSalt(val.create, targetModel, registry, options, false);
103
+ }
104
+ if (val.createMany && val.createMany.data) {
105
+ deepInjectSalt(val.createMany.data, targetModel, registry, options, false);
106
+ }
107
+ if (val.connectOrCreate && val.connectOrCreate.create) {
108
+ deepInjectSalt(val.connectOrCreate.create, targetModel, registry, options, false);
109
+ }
110
+ if (val.upsert && val.upsert.create) {
111
+ deepInjectSalt(val.upsert.create, targetModel, registry, options, false);
112
+ }
113
+ // Note: 'update' usually takes 'data', which might need injection if we allow updating ID?
114
+ // Usually ID/Salt is immutable, but if needed, add logic here.
115
+ }
116
+ }
117
+ }
118
+ }
119
+ // -----------------------------------------------------------------------------
120
+ // 2. 结果劫持 (Result Hijacking)
121
+ // -----------------------------------------------------------------------------
122
+ // 逻辑:保持原样,利用后缀匹配来隐藏 Salt 并劫持 Getter
123
+ function deepHijackResult(data, options) {
124
+ if (!data || typeof data !== "object")
125
+ return;
126
+ if (Array.isArray(data)) {
127
+ data.forEach((item) => deepHijackResult(item, options));
128
+ return;
129
+ }
130
+ const keys = Object.keys(data);
131
+ for (const key of keys) {
132
+ if (key.endsWith(options.saltSuffix)) {
133
+ const saltVal = data[key];
134
+ if (typeof saltVal === "number") {
135
+ const baseKey = key.slice(0, -options.saltSuffix.length);
136
+ const baseVal = data[baseKey];
137
+ if (typeof baseVal === "number") {
138
+ // 1. Hide Salt
139
+ Object.defineProperty(data, key, {
140
+ enumerable: false,
141
+ value: saltVal,
142
+ writable: true,
143
+ configurable: true,
144
+ });
145
+ // 2. Hijack Base ID
146
+ Object.defineProperty(data, baseKey, {
147
+ enumerable: true,
148
+ configurable: true,
149
+ get() {
150
+ return utils_1.SaltIdsHelper.encode(baseVal, saltVal, options.saltLength);
151
+ },
152
+ set(v) {
153
+ // no-op
154
+ },
155
+ });
156
+ }
157
+ }
158
+ }
159
+ const val = data[key];
160
+ if (typeof val === "object" && val !== null && !(val instanceof Date)) {
161
+ deepHijackResult(val, options);
162
+ }
163
+ }
164
+ }
@@ -0,0 +1,11 @@
1
+ export interface SaltIdsOptions {
2
+ /**
3
+ * Salt 长度 (默认为 4)
4
+ */
5
+ saltLength?: number;
6
+ /**
7
+ * Salt 字段后缀 (默认为 'Salt')
8
+ * 例如: userId -> userSalt
9
+ */
10
+ saltSuffix?: string;
11
+ }
package/dist/types.js ADDED
@@ -0,0 +1,2 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
@@ -0,0 +1,27 @@
1
+ export declare class SaltIdsHelper {
2
+ static encode(realId: number, salt: number, saltLen?: number): number;
3
+ static decode(pid: number, saltLen?: number): {
4
+ id: number;
5
+ salt: number;
6
+ };
7
+ static isPotentialSaltId(val: number, saltLen?: number): boolean;
8
+ static generateSalt(saltLen?: number): number;
9
+ }
10
+ export interface SaltField {
11
+ base: string;
12
+ salt: string;
13
+ }
14
+ export interface RelationField {
15
+ name: string;
16
+ type: string;
17
+ isList: boolean;
18
+ }
19
+ export declare class ModelRegistry {
20
+ private saltFields;
21
+ private relations;
22
+ private initialized;
23
+ init(dmmf: any, suffix: string): void;
24
+ private parse;
25
+ getSaltFields(model: string): SaltField[];
26
+ getRelation(model: string, field: string): RelationField | undefined;
27
+ }
package/dist/utils.js ADDED
@@ -0,0 +1,88 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.ModelRegistry = exports.SaltIdsHelper = void 0;
4
+ /**
5
+ * SaltID Helper (Pure Number)
6
+ */
7
+ const DEFAULT_SALT_LEN = 6;
8
+ class SaltIdsHelper {
9
+ static encode(realId, salt, saltLen = DEFAULT_SALT_LEN) {
10
+ return Number(`${salt}${realId}`);
11
+ }
12
+ static decode(pid, saltLen = DEFAULT_SALT_LEN) {
13
+ const str = pid.toString();
14
+ // 容错:长度不足则不做处理
15
+ if (str.length <= saltLen) {
16
+ return { id: pid, salt: 0 };
17
+ }
18
+ const saltStr = str.slice(0, saltLen);
19
+ const idStr = str.slice(saltLen);
20
+ return {
21
+ salt: parseInt(saltStr, 10),
22
+ id: parseInt(idStr, 10),
23
+ };
24
+ }
25
+ static isPotentialSaltId(val, saltLen = DEFAULT_SALT_LEN) {
26
+ return val.toString().length > saltLen;
27
+ }
28
+ static generateSalt(saltLen = DEFAULT_SALT_LEN) {
29
+ const min = Math.pow(10, saltLen - 1); // e.g. 100
30
+ const max = Math.pow(10, saltLen) - 1; // e.g. 999
31
+ return Math.floor(min + Math.random() * (max - min + 1));
32
+ }
33
+ }
34
+ exports.SaltIdsHelper = SaltIdsHelper;
35
+ class ModelRegistry {
36
+ constructor() {
37
+ this.saltFields = new Map();
38
+ this.relations = new Map();
39
+ this.initialized = false;
40
+ }
41
+ init(dmmf, suffix) {
42
+ if (this.initialized)
43
+ return;
44
+ this.parse(dmmf, suffix);
45
+ this.initialized = true;
46
+ }
47
+ parse(dmmf, suffix) {
48
+ const models = dmmf.datamodel.models;
49
+ for (const model of models) {
50
+ const fields = model.fields;
51
+ const validSalts = [];
52
+ const relationMap = new Map();
53
+ // 1. Map all Int fields
54
+ const intFields = new Set();
55
+ fields.forEach((f) => {
56
+ if (f.kind === "scalar" && f.type === "Int") {
57
+ intFields.add(f.name);
58
+ }
59
+ if (f.kind === "object") {
60
+ relationMap.set(f.name, {
61
+ name: f.name,
62
+ type: f.type,
63
+ isList: f.isList,
64
+ });
65
+ }
66
+ });
67
+ // 2. Find Pairs
68
+ intFields.forEach((fieldName) => {
69
+ // assumption: base field "xxx", salt field "xxxSalt" (if suffix is "Salt")
70
+ // Check if this field could be a base?
71
+ // If "postPk" exists, check if "postPkSalt" exists.
72
+ const potentialSaltName = `${fieldName}${suffix}`;
73
+ if (intFields.has(potentialSaltName)) {
74
+ validSalts.push({ base: fieldName, salt: potentialSaltName });
75
+ }
76
+ });
77
+ this.saltFields.set(model.name, validSalts);
78
+ this.relations.set(model.name, relationMap);
79
+ }
80
+ }
81
+ getSaltFields(model) {
82
+ return this.saltFields.get(model) || [];
83
+ }
84
+ getRelation(model, field) {
85
+ return this.relations.get(model)?.get(field);
86
+ }
87
+ }
88
+ exports.ModelRegistry = ModelRegistry;
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "prisma-extension-saltids",
3
+ "version": "1.0.2",
4
+ "description": "Transparently transform Auto-Increment Int ID + Random Salt into a single Public Int ID. Zero schema overhead.",
5
+ "main": "dist/index.js",
6
+ "types": "dist/index.d.ts",
7
+ "files": [
8
+ "dist"
9
+ ],
10
+ "scripts": {
11
+ "build": "tsc",
12
+ "test": "vitest run",
13
+ "prepublishOnly": "pnpm run build"
14
+ },
15
+ "keywords": [
16
+ "prisma",
17
+ "extension",
18
+ "saltids",
19
+ "hashids",
20
+ "sqids",
21
+ "id",
22
+ "salt"
23
+ ],
24
+ "repository": {
25
+ "type": "git",
26
+ "url": "git+https://github.com/pond918/prisma-extension-saltids.git"
27
+ },
28
+ "publishConfig": {
29
+ "access": "public",
30
+ "registry": "https://registry.npmjs.org/"
31
+ },
32
+ "author": "James P",
33
+ "license": "MIT",
34
+ "peerDependencies": {
35
+ "@prisma/client": "^5.0.0"
36
+ },
37
+ "devDependencies": {
38
+ "@types/node": "^20.0.0",
39
+ "prisma": "^5.0.0",
40
+ "typescript": "^5.0.0",
41
+ "vitest": "^4.0.18"
42
+ }
43
+ }