@zintrust/core 0.1.15 → 0.1.16
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/package.json +1 -1
- package/src/cli/CLI.d.ts.map +1 -1
- package/src/cli/CLI.js +6 -0
- package/src/cli/commands/BroadcastWorkCommand.d.ts +10 -0
- package/src/cli/commands/BroadcastWorkCommand.d.ts.map +1 -0
- package/src/cli/commands/BroadcastWorkCommand.js +16 -0
- package/src/cli/commands/NotificationWorkCommand.d.ts +10 -0
- package/src/cli/commands/NotificationWorkCommand.d.ts.map +1 -0
- package/src/cli/commands/NotificationWorkCommand.js +16 -0
- package/src/cli/commands/QueueCommand.d.ts +10 -0
- package/src/cli/commands/QueueCommand.d.ts.map +1 -0
- package/src/cli/commands/QueueCommand.js +63 -0
- package/src/cli/commands/QueueWorkCommandUtils.d.ts +10 -0
- package/src/cli/commands/QueueWorkCommandUtils.d.ts.map +1 -0
- package/src/cli/commands/QueueWorkCommandUtils.js +43 -0
- package/src/cli/commands/createKindWorkCommand.d.ts +9 -0
- package/src/cli/commands/createKindWorkCommand.d.ts.map +1 -0
- package/src/cli/commands/createKindWorkCommand.js +33 -0
- package/src/cli/commands/index.d.ts +3 -0
- package/src/cli/commands/index.d.ts.map +1 -1
- package/src/cli/commands/index.js +3 -0
- package/src/cli/scaffolding/ModelGenerator.d.ts.map +1 -1
- package/src/cli/scaffolding/ModelGenerator.js +1 -0
- package/src/cli/workers/QueueWorkRunner.d.ts +23 -0
- package/src/cli/workers/QueueWorkRunner.d.ts.map +1 -0
- package/src/cli/workers/QueueWorkRunner.js +142 -0
- package/src/collections/Collection.d.ts +30 -0
- package/src/collections/Collection.d.ts.map +1 -0
- package/src/collections/Collection.js +146 -0
- package/src/collections/index.d.ts +3 -0
- package/src/collections/index.d.ts.map +1 -0
- package/src/collections/index.js +1 -0
- package/src/events/EventDispatcher.d.ts +16 -0
- package/src/events/EventDispatcher.d.ts.map +1 -0
- package/src/events/EventDispatcher.js +90 -0
- package/src/events/index.d.ts +3 -0
- package/src/events/index.d.ts.map +1 -0
- package/src/events/index.js +1 -0
- package/src/features/Queue.js +1 -1
- package/src/http/Response.d.ts +2 -2
- package/src/http/Response.d.ts.map +1 -1
- package/src/index.d.ts +11 -0
- package/src/index.d.ts.map +1 -1
- package/src/index.js +11 -0
- package/src/middleware/CsrfMiddleware.d.ts.map +1 -1
- package/src/middleware/CsrfMiddleware.js +20 -25
- package/src/middleware/SessionMiddleware.d.ts +8 -0
- package/src/middleware/SessionMiddleware.d.ts.map +1 -0
- package/src/middleware/SessionMiddleware.js +15 -0
- package/src/orm/Model.d.ts +15 -0
- package/src/orm/Model.d.ts.map +1 -1
- package/src/orm/Model.js +57 -8
- package/src/orm/QueryBuilder.d.ts +9 -1
- package/src/orm/QueryBuilder.d.ts.map +1 -1
- package/src/orm/QueryBuilder.js +54 -2
- package/src/scripts/TemplateSync.js +23 -1
- package/src/security/PasswordResetTokenBroker.d.ts +39 -0
- package/src/security/PasswordResetTokenBroker.d.ts.map +1 -0
- package/src/security/PasswordResetTokenBroker.js +131 -0
- package/src/session/SessionManager.d.ts +39 -0
- package/src/session/SessionManager.d.ts.map +1 -0
- package/src/session/SessionManager.js +149 -0
- package/src/session/index.d.ts +3 -0
- package/src/session/index.d.ts.map +1 -0
- package/src/session/index.js +1 -0
- package/src/templates/features/Queue.ts.tpl +4 -3
- package/src/templates/project/basic/config/FileLogWriter.ts.tpl +4 -3
- package/src/templates/project/basic/config/SecretsManager.ts.tpl +1 -1
- package/src/templates/project/basic/config/broadcast.ts.tpl +2 -2
- package/src/templates/project/basic/config/cache.ts.tpl +2 -2
- package/src/templates/project/basic/config/database.ts.tpl +2 -2
- package/src/templates/project/basic/config/features.ts.tpl +2 -2
- package/src/templates/project/basic/config/logger.ts.tpl +0 -2
- package/src/templates/project/basic/config/logging/HttpLogger.ts.tpl +1 -1
- package/src/templates/project/basic/config/logging/SlackLogger.ts.tpl +1 -1
- package/src/templates/project/basic/config/mail.ts.tpl +2 -2
- package/src/templates/project/basic/config/microservices.ts.tpl +1 -1
- package/src/templates/project/basic/config/middleware.ts.tpl +6 -9
- package/src/templates/project/basic/config/notification.ts.tpl +2 -2
- package/src/templates/project/basic/config/security.ts.tpl +1 -2
- package/src/templates/project/basic/config/storage.ts.tpl +2 -2
- package/src/templates/project/basic/config/type.ts.tpl +2 -2
- package/src/tools/broadcast/Broadcast.d.ts +8 -0
- package/src/tools/broadcast/Broadcast.d.ts.map +1 -1
- package/src/tools/broadcast/Broadcast.js +23 -0
- package/src/tools/notification/Notification.d.ts +10 -0
- package/src/tools/notification/Notification.d.ts.map +1 -1
- package/src/tools/notification/Notification.js +21 -0
- package/src/workers/BroadcastWorker.d.ts +22 -0
- package/src/workers/BroadcastWorker.d.ts.map +1 -0
- package/src/workers/BroadcastWorker.js +24 -0
- package/src/workers/NotificationWorker.d.ts +22 -0
- package/src/workers/NotificationWorker.d.ts.map +1 -0
- package/src/workers/NotificationWorker.js +23 -0
- package/src/workers/createQueueWorker.d.ts +24 -0
- package/src/workers/createQueueWorker.d.ts.map +1 -0
- package/src/workers/createQueueWorker.js +114 -0
package/src/orm/Model.js
CHANGED
|
@@ -43,7 +43,27 @@ const castAttribute = (config, key, value) => {
|
|
|
43
43
|
const fillAttributes = (config, attrs, newAttrs) => {
|
|
44
44
|
for (const [key, value] of Object.entries(newAttrs)) {
|
|
45
45
|
if (config.fillable.length === 0 || config.fillable.includes(key)) {
|
|
46
|
-
|
|
46
|
+
const mutator = config.mutators?.[key];
|
|
47
|
+
const nextValue = mutator ? mutator(value, attrs) : value;
|
|
48
|
+
attrs[key] = castAttribute(config, key, nextValue);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
const applyAccessor = (config, key, attrs) => {
|
|
53
|
+
const raw = attrs[key];
|
|
54
|
+
const accessor = config.accessors?.[key];
|
|
55
|
+
return accessor ? accessor(raw, attrs) : raw;
|
|
56
|
+
};
|
|
57
|
+
const runObservers = async (config, hook, model) => {
|
|
58
|
+
const observers = config.observers;
|
|
59
|
+
if (observers === undefined || observers.length === 0)
|
|
60
|
+
return;
|
|
61
|
+
for (const observer of observers) {
|
|
62
|
+
const fn = observer[hook];
|
|
63
|
+
if (typeof fn === 'function') {
|
|
64
|
+
// Observers intentionally run sequentially.
|
|
65
|
+
// eslint-disable-next-line no-await-in-loop
|
|
66
|
+
await fn(model);
|
|
47
67
|
}
|
|
48
68
|
}
|
|
49
69
|
};
|
|
@@ -85,29 +105,36 @@ export const createModel = (config, attributes = {}) => {
|
|
|
85
105
|
return model;
|
|
86
106
|
},
|
|
87
107
|
setAttribute: (key, value) => {
|
|
88
|
-
|
|
108
|
+
const mutator = config.mutators?.[key];
|
|
109
|
+
const nextValue = mutator ? mutator(value, attrs) : value;
|
|
110
|
+
attrs[key] = castAttribute(config, key, nextValue);
|
|
89
111
|
return model;
|
|
90
112
|
},
|
|
91
|
-
getAttribute: (key) =>
|
|
113
|
+
getAttribute: (key) => applyAccessor(config, key, attrs),
|
|
92
114
|
getAttributes: () => ({ ...attrs }),
|
|
93
115
|
// remove in production - use saveChanges pattern
|
|
94
|
-
// eslint-disable-next-line @typescript-eslint/require-await
|
|
95
116
|
async save() {
|
|
96
117
|
if (db === undefined)
|
|
97
118
|
throw ErrorFactory.createDatabaseError('Database not initialized');
|
|
119
|
+
const isCreate = isExists === false;
|
|
120
|
+
await runObservers(config, 'saving', model);
|
|
121
|
+
await runObservers(config, isCreate ? 'creating' : 'updating', model);
|
|
98
122
|
if (config.timestamps) {
|
|
99
123
|
attrs['created_at'] = attrs['created_at'] ?? new Date().toISOString();
|
|
100
124
|
attrs['updated_at'] = new Date().toISOString();
|
|
101
125
|
}
|
|
102
126
|
isExists = true;
|
|
103
127
|
original = { ...attrs };
|
|
128
|
+
await runObservers(config, isCreate ? 'created' : 'updated', model);
|
|
129
|
+
await runObservers(config, 'saved', model);
|
|
104
130
|
return true;
|
|
105
131
|
},
|
|
106
132
|
// remove in production - use delete pattern
|
|
107
|
-
// eslint-disable-next-line @typescript-eslint/require-await
|
|
108
133
|
async delete() {
|
|
109
134
|
if (!isExists || db === undefined)
|
|
110
135
|
return false;
|
|
136
|
+
await runObservers(config, 'deleting', model);
|
|
137
|
+
await runObservers(config, 'deleted', model);
|
|
111
138
|
return true;
|
|
112
139
|
},
|
|
113
140
|
toJSON: () => createModelJSON(config, attrs),
|
|
@@ -130,11 +157,17 @@ export const query = (table, connection) => {
|
|
|
130
157
|
const db = useDatabase(undefined, connection ?? DEFAULTS.CONNECTION);
|
|
131
158
|
return QueryBuilder.create(table, db);
|
|
132
159
|
};
|
|
160
|
+
const buildSoftDeleteOptions = (config) => {
|
|
161
|
+
if (config.softDeletes !== true)
|
|
162
|
+
return undefined;
|
|
163
|
+
return { softDeleteColumn: 'deleted_at', softDeleteMode: 'exclude' };
|
|
164
|
+
};
|
|
133
165
|
/**
|
|
134
166
|
* Find a model by ID
|
|
135
167
|
*/
|
|
136
168
|
export const find = async (config, id) => {
|
|
137
|
-
const
|
|
169
|
+
const db = useDatabase(undefined, config.connection ?? DEFAULTS.CONNECTION);
|
|
170
|
+
const builder = QueryBuilder.create(config.table, db, buildSoftDeleteOptions(config));
|
|
138
171
|
builder.where('id', '=', String(id)).limit(1);
|
|
139
172
|
const result = await builder.first();
|
|
140
173
|
if (result === null)
|
|
@@ -147,7 +180,8 @@ export const find = async (config, id) => {
|
|
|
147
180
|
* Get all records for a model
|
|
148
181
|
*/
|
|
149
182
|
export const all = async (config) => {
|
|
150
|
-
const
|
|
183
|
+
const db = useDatabase(undefined, config.connection ?? DEFAULTS.CONNECTION);
|
|
184
|
+
const builder = QueryBuilder.create(config.table, db, buildSoftDeleteOptions(config));
|
|
151
185
|
const results = await builder.get();
|
|
152
186
|
return results.map((result) => {
|
|
153
187
|
const model = createModel(config, result);
|
|
@@ -187,7 +221,22 @@ export function define(config, methodsOrPlan) {
|
|
|
187
221
|
const models = await all(cfg);
|
|
188
222
|
return models.map((m) => attach(m));
|
|
189
223
|
},
|
|
190
|
-
query: () =>
|
|
224
|
+
query: () => {
|
|
225
|
+
const db = useDatabase(undefined, cfg.connection ?? DEFAULTS.CONNECTION);
|
|
226
|
+
return QueryBuilder.create(cfg.table, db, buildSoftDeleteOptions(cfg));
|
|
227
|
+
},
|
|
228
|
+
scope: (name, ...args) => {
|
|
229
|
+
const scopes = cfg.scopes;
|
|
230
|
+
const fn = scopes?.[name];
|
|
231
|
+
if (typeof fn !== 'function') {
|
|
232
|
+
throw ErrorFactory.createConfigError(`Unknown query scope: ${name}`);
|
|
233
|
+
}
|
|
234
|
+
const builder = (() => {
|
|
235
|
+
const db = useDatabase(undefined, cfg.connection ?? DEFAULTS.CONNECTION);
|
|
236
|
+
return QueryBuilder.create(cfg.table, db, buildSoftDeleteOptions(cfg));
|
|
237
|
+
})();
|
|
238
|
+
return fn(builder, ...args);
|
|
239
|
+
},
|
|
191
240
|
getTable: () => cfg.table,
|
|
192
241
|
db: (connection) => createDefinedModel({ ...cfg, connection }),
|
|
193
242
|
});
|
|
@@ -8,11 +8,19 @@ export interface WhereClause {
|
|
|
8
8
|
operator: string;
|
|
9
9
|
value: unknown;
|
|
10
10
|
}
|
|
11
|
+
export type SoftDeleteMode = 'exclude' | 'include' | 'only';
|
|
12
|
+
export interface QueryBuilderOptions {
|
|
13
|
+
softDeleteColumn?: string;
|
|
14
|
+
softDeleteMode?: SoftDeleteMode;
|
|
15
|
+
}
|
|
11
16
|
export interface IQueryBuilder {
|
|
12
17
|
select(...columns: string[]): IQueryBuilder;
|
|
13
18
|
where(column: string, operator: string | number | boolean | null, value?: unknown): IQueryBuilder;
|
|
14
19
|
andWhere(column: string, operator: string, value?: unknown): IQueryBuilder;
|
|
15
20
|
orWhere(column: string, operator: string, value?: unknown): IQueryBuilder;
|
|
21
|
+
withTrashed(): IQueryBuilder;
|
|
22
|
+
onlyTrashed(): IQueryBuilder;
|
|
23
|
+
withoutTrashed(): IQueryBuilder;
|
|
16
24
|
join(table: string, on: string): IQueryBuilder;
|
|
17
25
|
leftJoin(table: string, on: string): IQueryBuilder;
|
|
18
26
|
orderBy(column: string, direction?: 'ASC' | 'DESC'): IQueryBuilder;
|
|
@@ -47,7 +55,7 @@ export declare const QueryBuilder: Readonly<{
|
|
|
47
55
|
/**
|
|
48
56
|
* Create a new query builder instance
|
|
49
57
|
*/
|
|
50
|
-
create(tableOrDb: string | IDatabase, db?: IDatabase): IQueryBuilder;
|
|
58
|
+
create(tableOrDb: string | IDatabase, db?: IDatabase, options?: QueryBuilderOptions): IQueryBuilder;
|
|
51
59
|
/**
|
|
52
60
|
* Ping the database connection.
|
|
53
61
|
*
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"QueryBuilder.d.ts","sourceRoot":"","sources":["../../../src/orm/QueryBuilder.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAGH,OAAO,EAAE,SAAS,EAAE,MAAM,eAAe,CAAC;AAE1C,MAAM,WAAW,WAAW;IAC1B,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,EAAE,OAAO,CAAC;CAChB;AAED,MAAM,WAAW,aAAa;IAC5B,MAAM,CAAC,GAAG,OAAO,EAAE,MAAM,EAAE,GAAG,aAAa,CAAC;IAC5C,KAAK,CAAC,MAAM,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,MAAM,GAAG,OAAO,GAAG,IAAI,EAAE,KAAK,CAAC,EAAE,OAAO,GAAG,aAAa,CAAC;IAClG,QAAQ,CAAC,MAAM,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,KAAK,CAAC,EAAE,OAAO,GAAG,aAAa,CAAC;IAC3E,OAAO,CAAC,MAAM,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,KAAK,CAAC,EAAE,OAAO,GAAG,aAAa,CAAC;IAC1E,IAAI,CAAC,KAAK,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,GAAG,aAAa,CAAC;IAC/C,QAAQ,CAAC,KAAK,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,GAAG,aAAa,CAAC;IACnD,OAAO,CAAC,MAAM,EAAE,MAAM,EAAE,SAAS,CAAC,EAAE,KAAK,GAAG,MAAM,GAAG,aAAa,CAAC;IACnE,KAAK,CAAC,KAAK,EAAE,MAAM,GAAG,aAAa,CAAC;IACpC,MAAM,CAAC,KAAK,EAAE,MAAM,GAAG,aAAa,CAAC;IACrC,eAAe,IAAI,WAAW,EAAE,CAAC;IACjC,gBAAgB,IAAI,MAAM,EAAE,CAAC;IAC7B,QAAQ,IAAI,MAAM,CAAC;IACnB,QAAQ,IAAI,MAAM,GAAG,SAAS,CAAC;IAC/B,SAAS,IAAI,MAAM,GAAG,SAAS,CAAC;IAChC,UAAU,IAAI;QAAE,MAAM,EAAE,MAAM,CAAC;QAAC,SAAS,EAAE,KAAK,GAAG,MAAM,CAAA;KAAE,GAAG,SAAS,CAAC;IACxE,QAAQ,IAAI,KAAK,CAAC;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,EAAE,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IACjD,eAAe,IAAI,OAAO,CAAC;IAC3B,KAAK,IAAI,MAAM,CAAC;IAChB,aAAa,IAAI,OAAO,EAAE,CAAC;IAC3B,KAAK,CAAC,CAAC,KAAK,OAAO,CAAC,CAAC,GAAG,IAAI,CAAC,CAAC;IAC9B,GAAG,CAAC,CAAC,KAAK,OAAO,CAAC,CAAC,EAAE,CAAC,CAAC;CACxB;
|
|
1
|
+
{"version":3,"file":"QueryBuilder.d.ts","sourceRoot":"","sources":["../../../src/orm/QueryBuilder.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAGH,OAAO,EAAE,SAAS,EAAE,MAAM,eAAe,CAAC;AAE1C,MAAM,WAAW,WAAW;IAC1B,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,EAAE,OAAO,CAAC;CAChB;AAED,MAAM,MAAM,cAAc,GAAG,SAAS,GAAG,SAAS,GAAG,MAAM,CAAC;AAE5D,MAAM,WAAW,mBAAmB;IAClC,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,cAAc,CAAC,EAAE,cAAc,CAAC;CACjC;AAED,MAAM,WAAW,aAAa;IAC5B,MAAM,CAAC,GAAG,OAAO,EAAE,MAAM,EAAE,GAAG,aAAa,CAAC;IAC5C,KAAK,CAAC,MAAM,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,MAAM,GAAG,OAAO,GAAG,IAAI,EAAE,KAAK,CAAC,EAAE,OAAO,GAAG,aAAa,CAAC;IAClG,QAAQ,CAAC,MAAM,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,KAAK,CAAC,EAAE,OAAO,GAAG,aAAa,CAAC;IAC3E,OAAO,CAAC,MAAM,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,KAAK,CAAC,EAAE,OAAO,GAAG,aAAa,CAAC;IAC1E,WAAW,IAAI,aAAa,CAAC;IAC7B,WAAW,IAAI,aAAa,CAAC;IAC7B,cAAc,IAAI,aAAa,CAAC;IAChC,IAAI,CAAC,KAAK,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,GAAG,aAAa,CAAC;IAC/C,QAAQ,CAAC,KAAK,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,GAAG,aAAa,CAAC;IACnD,OAAO,CAAC,MAAM,EAAE,MAAM,EAAE,SAAS,CAAC,EAAE,KAAK,GAAG,MAAM,GAAG,aAAa,CAAC;IACnE,KAAK,CAAC,KAAK,EAAE,MAAM,GAAG,aAAa,CAAC;IACpC,MAAM,CAAC,KAAK,EAAE,MAAM,GAAG,aAAa,CAAC;IACrC,eAAe,IAAI,WAAW,EAAE,CAAC;IACjC,gBAAgB,IAAI,MAAM,EAAE,CAAC;IAC7B,QAAQ,IAAI,MAAM,CAAC;IACnB,QAAQ,IAAI,MAAM,GAAG,SAAS,CAAC;IAC/B,SAAS,IAAI,MAAM,GAAG,SAAS,CAAC;IAChC,UAAU,IAAI;QAAE,MAAM,EAAE,MAAM,CAAC;QAAC,SAAS,EAAE,KAAK,GAAG,MAAM,CAAA;KAAE,GAAG,SAAS,CAAC;IACxE,QAAQ,IAAI,KAAK,CAAC;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,EAAE,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IACjD,eAAe,IAAI,OAAO,CAAC;IAC3B,KAAK,IAAI,MAAM,CAAC;IAChB,aAAa,IAAI,OAAO,EAAE,CAAC;IAC3B,KAAK,CAAC,CAAC,KAAK,OAAO,CAAC,CAAC,GAAG,IAAI,CAAC,CAAC;IAC9B,GAAG,CAAC,CAAC,KAAK,OAAO,CAAC,CAAC,EAAE,CAAC,CAAC;CACxB;AAgWD;;;;;GAKG;AACH,eAAO,MAAM,YAAY;IACvB;;OAEG;sBAEU,MAAM,GAAG,SAAS,OACxB,SAAS,YACL,mBAAmB,GAC3B,aAAa;IAyBhB;;;;;OAKG;aACY,SAAS,GAAG,OAAO,CAAC,IAAI,CAAC;EAIxC,CAAC"}
|
package/src/orm/QueryBuilder.js
CHANGED
|
@@ -117,6 +117,25 @@ const compileWhere = (conditions) => {
|
|
|
117
117
|
});
|
|
118
118
|
return { sql: ` WHERE ${clauses.join(' AND ')}`, parameters };
|
|
119
119
|
};
|
|
120
|
+
const buildSoftDeleteWhereClause = (column, mode) => {
|
|
121
|
+
const col = column.trim();
|
|
122
|
+
if (col.length === 0)
|
|
123
|
+
return null;
|
|
124
|
+
assertSafeIdentifierPath(col, 'soft delete column');
|
|
125
|
+
if (mode === 'include')
|
|
126
|
+
return null;
|
|
127
|
+
if (mode === 'only')
|
|
128
|
+
return { column: col, operator: 'IS NOT', value: null };
|
|
129
|
+
return { column: col, operator: 'IS', value: null };
|
|
130
|
+
};
|
|
131
|
+
const getEffectiveWhereConditions = (state) => {
|
|
132
|
+
if (state.softDelete === undefined)
|
|
133
|
+
return state.whereConditions;
|
|
134
|
+
const clause = buildSoftDeleteWhereClause(state.softDelete.column, state.softDelete.mode);
|
|
135
|
+
if (clause === null)
|
|
136
|
+
return state.whereConditions;
|
|
137
|
+
return [...state.whereConditions, clause];
|
|
138
|
+
};
|
|
120
139
|
/**
|
|
121
140
|
* Build ORDER BY clause
|
|
122
141
|
*/
|
|
@@ -152,7 +171,7 @@ const buildSelectQuery = (state) => {
|
|
|
152
171
|
}
|
|
153
172
|
const columns = buildSelectClause(state.selectColumns);
|
|
154
173
|
const fromClause = state.tableName.length > 0 ? ` FROM ${escapeIdentifier(state.tableName)}` : '';
|
|
155
|
-
const where = compileWhere(state
|
|
174
|
+
const where = compileWhere(getEffectiveWhereConditions(state));
|
|
156
175
|
const sql = `SELECT ${columns}${fromClause}${where.sql}${buildOrderByClause(state.orderByClause)}${buildLimitOffsetClause(state.limitValue, state.offsetValue)}`;
|
|
157
176
|
return { sql, parameters: where.parameters };
|
|
158
177
|
};
|
|
@@ -224,6 +243,33 @@ function createBuilder(state, db) {
|
|
|
224
243
|
},
|
|
225
244
|
andWhere: (column, operator, value) => builder.where(column, operator, value),
|
|
226
245
|
orWhere: (column, operator, value) => builder.where(column, operator, value),
|
|
246
|
+
withTrashed: () => {
|
|
247
|
+
if (state.softDelete === undefined) {
|
|
248
|
+
state.softDelete = { column: 'deleted_at', mode: 'include' };
|
|
249
|
+
}
|
|
250
|
+
else {
|
|
251
|
+
state.softDelete.mode = 'include';
|
|
252
|
+
}
|
|
253
|
+
return builder;
|
|
254
|
+
},
|
|
255
|
+
onlyTrashed: () => {
|
|
256
|
+
if (state.softDelete === undefined) {
|
|
257
|
+
state.softDelete = { column: 'deleted_at', mode: 'only' };
|
|
258
|
+
}
|
|
259
|
+
else {
|
|
260
|
+
state.softDelete.mode = 'only';
|
|
261
|
+
}
|
|
262
|
+
return builder;
|
|
263
|
+
},
|
|
264
|
+
withoutTrashed: () => {
|
|
265
|
+
if (state.softDelete === undefined) {
|
|
266
|
+
state.softDelete = { column: 'deleted_at', mode: 'exclude' };
|
|
267
|
+
}
|
|
268
|
+
else {
|
|
269
|
+
state.softDelete.mode = 'exclude';
|
|
270
|
+
}
|
|
271
|
+
return builder;
|
|
272
|
+
},
|
|
227
273
|
join: (tableJoin, on) => {
|
|
228
274
|
state.joins.push({ table: tableJoin, on });
|
|
229
275
|
return builder;
|
|
@@ -266,7 +312,7 @@ export const QueryBuilder = Object.freeze({
|
|
|
266
312
|
/**
|
|
267
313
|
* Create a new query builder instance
|
|
268
314
|
*/
|
|
269
|
-
create(tableOrDb, db) {
|
|
315
|
+
create(tableOrDb, db, options = {}) {
|
|
270
316
|
const hasTable = typeof tableOrDb === 'string';
|
|
271
317
|
const tableName = hasTable ? String(tableOrDb).trim() : '';
|
|
272
318
|
const database = hasTable ? db : tableOrDb;
|
|
@@ -279,6 +325,12 @@ export const QueryBuilder = Object.freeze({
|
|
|
279
325
|
selectColumns: ['*'],
|
|
280
326
|
joins: [],
|
|
281
327
|
};
|
|
328
|
+
if (options.softDeleteColumn !== undefined && options.softDeleteColumn.trim().length > 0) {
|
|
329
|
+
state.softDelete = {
|
|
330
|
+
column: options.softDeleteColumn.trim(),
|
|
331
|
+
mode: options.softDeleteMode ?? 'exclude',
|
|
332
|
+
};
|
|
333
|
+
}
|
|
282
334
|
return createBuilder(state, database);
|
|
283
335
|
},
|
|
284
336
|
/**
|
|
@@ -10,6 +10,7 @@ import { ErrorFactory } from '../exceptions/ZintrustError.js';
|
|
|
10
10
|
import * as crypto from '../node-singletons/crypto.js';
|
|
11
11
|
import fs from '../node-singletons/fs.js';
|
|
12
12
|
import * as path from '../node-singletons/path.js';
|
|
13
|
+
import * as nodePath from 'node:path';
|
|
13
14
|
const __dirname = esmDirname(import.meta.url);
|
|
14
15
|
const ROOT_DIR = path.resolve(__dirname, '../../');
|
|
15
16
|
/**
|
|
@@ -115,9 +116,30 @@ const rewriteStarterTemplateImports = (relPath, content) => {
|
|
|
115
116
|
if (!relPath.endsWith('.ts') && !relPath.endsWith('.tsx') && !relPath.endsWith('.mts')) {
|
|
116
117
|
return content;
|
|
117
118
|
}
|
|
119
|
+
const rewriteConfigAlias = (aliasSuffix) => {
|
|
120
|
+
const currentDir = nodePath.posix.dirname(relPath);
|
|
121
|
+
const from = currentDir === '.' ? '' : currentDir;
|
|
122
|
+
const target = aliasSuffix;
|
|
123
|
+
const relative = nodePath.posix.relative(from, target);
|
|
124
|
+
return relative.startsWith('.') ? relative : `./${relative}`;
|
|
125
|
+
};
|
|
118
126
|
// Starter templates should import framework APIs from the public package surface,
|
|
119
127
|
// not from internal path-alias modules that only exist in the framework repo.
|
|
120
128
|
return (content
|
|
129
|
+
// Node-singletons are internal to this repo; starter templates should use Node built-ins.
|
|
130
|
+
.replaceAll("'@node-singletons/fs'", "'node:fs'")
|
|
131
|
+
.replaceAll('"@node-singletons/fs"', '"node:fs"')
|
|
132
|
+
.replaceAll("'@node-singletons/path'", "'node:path'")
|
|
133
|
+
.replaceAll('"@node-singletons/path"', '"node:path"')
|
|
134
|
+
// Starter project config/* should reference sibling config modules via relative imports.
|
|
135
|
+
.replaceAll(/(['"])@config\/([^'"]+)\1/g, (_m, quote, suffix) => {
|
|
136
|
+
const rewritten = rewriteConfigAlias(suffix);
|
|
137
|
+
return `${quote}${rewritten}${quote}`;
|
|
138
|
+
})
|
|
139
|
+
// Middleware imports are framework APIs; they must come from the public package.
|
|
140
|
+
.replaceAll(/(['"])@middleware\/[^'"]+\1/g, (_m, quote) => {
|
|
141
|
+
return `${quote}@zintrust/core${quote}`;
|
|
142
|
+
})
|
|
121
143
|
.replaceAll("'@routing/Router'", "'@zintrust/core'")
|
|
122
144
|
.replaceAll("'@orm/Database'", "'@zintrust/core'")
|
|
123
145
|
.replaceAll("'@orm/QueryBuilder'", "'@zintrust/core'")
|
|
@@ -217,7 +239,7 @@ const syncStarterProjectTemplates = (params) => {
|
|
|
217
239
|
templateDirRel: `${params.projectRoot}/config`,
|
|
218
240
|
description: 'Starter project config/* (from src/config/*)',
|
|
219
241
|
transformContent: rewriteStarterTemplateImports,
|
|
220
|
-
checksumSalt: 'starter-imports-
|
|
242
|
+
checksumSalt: 'starter-imports-v4',
|
|
221
243
|
});
|
|
222
244
|
const s3 = syncProjectTemplateDir({
|
|
223
245
|
checksums: params.checksums,
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Password Reset Token Broker
|
|
3
|
+
*
|
|
4
|
+
* Framework-agnostic, storage-pluggable password reset token flow.
|
|
5
|
+
*
|
|
6
|
+
* - Generates high-entropy tokens for a given identifier (usually an email).
|
|
7
|
+
* - Stores only a SHA-256 hash of the token (one active token per identifier).
|
|
8
|
+
* - Supports verification and one-time consumption.
|
|
9
|
+
*/
|
|
10
|
+
export interface PasswordResetTokenRecord {
|
|
11
|
+
identifier: string;
|
|
12
|
+
tokenHash: string;
|
|
13
|
+
createdAt: Date;
|
|
14
|
+
expiresAt: Date;
|
|
15
|
+
}
|
|
16
|
+
export interface IPasswordResetTokenStore {
|
|
17
|
+
set(record: PasswordResetTokenRecord): void | Promise<void>;
|
|
18
|
+
get(identifier: string): PasswordResetTokenRecord | null | Promise<PasswordResetTokenRecord | null>;
|
|
19
|
+
delete(identifier: string): void | Promise<void>;
|
|
20
|
+
cleanup?(now?: Date): number | Promise<number>;
|
|
21
|
+
clear?(): void | Promise<void>;
|
|
22
|
+
}
|
|
23
|
+
export interface IPasswordResetTokenBroker {
|
|
24
|
+
createToken(identifier: string): Promise<string>;
|
|
25
|
+
verifyToken(identifier: string, token: string): Promise<boolean>;
|
|
26
|
+
consumeToken(identifier: string, token: string): Promise<boolean>;
|
|
27
|
+
}
|
|
28
|
+
export interface PasswordResetTokenBrokerOptions {
|
|
29
|
+
store?: IPasswordResetTokenStore;
|
|
30
|
+
ttlMs?: number;
|
|
31
|
+
tokenBytes?: number;
|
|
32
|
+
now?: () => Date;
|
|
33
|
+
}
|
|
34
|
+
export interface PasswordResetTokenBrokerType {
|
|
35
|
+
create(options?: PasswordResetTokenBrokerOptions): IPasswordResetTokenBroker;
|
|
36
|
+
createInMemoryStore(): IPasswordResetTokenStore;
|
|
37
|
+
}
|
|
38
|
+
export declare const PasswordResetTokenBroker: PasswordResetTokenBrokerType;
|
|
39
|
+
//# sourceMappingURL=PasswordResetTokenBroker.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"PasswordResetTokenBroker.d.ts","sourceRoot":"","sources":["../../../src/security/PasswordResetTokenBroker.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAKH,MAAM,WAAW,wBAAwB;IACvC,UAAU,EAAE,MAAM,CAAC;IACnB,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,IAAI,CAAC;IAChB,SAAS,EAAE,IAAI,CAAC;CACjB;AAED,MAAM,WAAW,wBAAwB;IACvC,GAAG,CAAC,MAAM,EAAE,wBAAwB,GAAG,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC5D,GAAG,CACD,UAAU,EAAE,MAAM,GACjB,wBAAwB,GAAG,IAAI,GAAG,OAAO,CAAC,wBAAwB,GAAG,IAAI,CAAC,CAAC;IAC9E,MAAM,CAAC,UAAU,EAAE,MAAM,GAAG,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACjD,OAAO,CAAC,CAAC,GAAG,CAAC,EAAE,IAAI,GAAG,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;IAC/C,KAAK,CAAC,IAAI,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CAChC;AAED,MAAM,WAAW,yBAAyB;IACxC,WAAW,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;IACjD,WAAW,CAAC,UAAU,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;IACjE,YAAY,CAAC,UAAU,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;CACnE;AAED,MAAM,WAAW,+BAA+B;IAC9C,KAAK,CAAC,EAAE,wBAAwB,CAAC;IACjC,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,GAAG,CAAC,EAAE,MAAM,IAAI,CAAC;CAClB;AAED,MAAM,WAAW,4BAA4B;IAC3C,MAAM,CAAC,OAAO,CAAC,EAAE,+BAA+B,GAAG,yBAAyB,CAAC;IAC7E,mBAAmB,IAAI,wBAAwB,CAAC;CACjD;AAmFD,eAAO,MAAM,wBAAwB,EAAE,4BAGrC,CAAC"}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Password Reset Token Broker
|
|
3
|
+
*
|
|
4
|
+
* Framework-agnostic, storage-pluggable password reset token flow.
|
|
5
|
+
*
|
|
6
|
+
* - Generates high-entropy tokens for a given identifier (usually an email).
|
|
7
|
+
* - Stores only a SHA-256 hash of the token (one active token per identifier).
|
|
8
|
+
* - Supports verification and one-time consumption.
|
|
9
|
+
*/
|
|
10
|
+
import { ErrorFactory } from '../exceptions/ZintrustError.js';
|
|
11
|
+
import { createHash, randomBytes } from '../node-singletons/crypto.js';
|
|
12
|
+
const DEFAULT_TTL_MS = 30 * 60 * 1000;
|
|
13
|
+
const DEFAULT_TOKEN_BYTES = 32; // 256 bits
|
|
14
|
+
const createInMemoryStore = () => {
|
|
15
|
+
const map = new Map();
|
|
16
|
+
return {
|
|
17
|
+
set(record) {
|
|
18
|
+
map.set(record.identifier, record);
|
|
19
|
+
},
|
|
20
|
+
get(identifier) {
|
|
21
|
+
return map.get(identifier) ?? null;
|
|
22
|
+
},
|
|
23
|
+
delete(identifier) {
|
|
24
|
+
map.delete(identifier);
|
|
25
|
+
},
|
|
26
|
+
cleanup(now = new Date()) {
|
|
27
|
+
let removed = 0;
|
|
28
|
+
for (const [identifier, record] of map.entries()) {
|
|
29
|
+
if (now.getTime() > record.expiresAt.getTime()) {
|
|
30
|
+
map.delete(identifier);
|
|
31
|
+
removed++;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return removed;
|
|
35
|
+
},
|
|
36
|
+
clear() {
|
|
37
|
+
map.clear();
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
};
|
|
41
|
+
const create = (options = {}) => {
|
|
42
|
+
const store = options.store ?? createInMemoryStore();
|
|
43
|
+
const ttlMs = normalizeTtlMs(options.ttlMs ?? DEFAULT_TTL_MS);
|
|
44
|
+
const tokenBytes = normalizeTokenBytes(options.tokenBytes ?? DEFAULT_TOKEN_BYTES);
|
|
45
|
+
const now = options.now ?? (() => new Date());
|
|
46
|
+
return {
|
|
47
|
+
async createToken(identifier) {
|
|
48
|
+
const normalizedIdentifier = normalizeIdentifier(identifier);
|
|
49
|
+
const token = randomBytes(tokenBytes).toString('hex');
|
|
50
|
+
const tokenHash = sha256Hex(token);
|
|
51
|
+
const createdAt = now();
|
|
52
|
+
const expiresAt = new Date(createdAt.getTime() + ttlMs);
|
|
53
|
+
await store.set({ identifier: normalizedIdentifier, tokenHash, createdAt, expiresAt });
|
|
54
|
+
return token;
|
|
55
|
+
},
|
|
56
|
+
async verifyToken(identifier, token) {
|
|
57
|
+
const normalizedIdentifier = normalizeIdentifier(identifier);
|
|
58
|
+
const normalizedToken = normalizeToken(token);
|
|
59
|
+
const record = await store.get(normalizedIdentifier);
|
|
60
|
+
if (record === null)
|
|
61
|
+
return false;
|
|
62
|
+
if (isExpired(record, now())) {
|
|
63
|
+
await store.delete(normalizedIdentifier);
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
const computed = sha256Hex(normalizedToken);
|
|
67
|
+
return timingSafeEquals(record.tokenHash, computed);
|
|
68
|
+
},
|
|
69
|
+
async consumeToken(identifier, token) {
|
|
70
|
+
const normalizedIdentifier = normalizeIdentifier(identifier);
|
|
71
|
+
const ok = await this.verifyToken(normalizedIdentifier, token);
|
|
72
|
+
if (!ok)
|
|
73
|
+
return false;
|
|
74
|
+
await store.delete(normalizedIdentifier);
|
|
75
|
+
return true;
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
};
|
|
79
|
+
export const PasswordResetTokenBroker = Object.freeze({
|
|
80
|
+
create,
|
|
81
|
+
createInMemoryStore,
|
|
82
|
+
});
|
|
83
|
+
function normalizeIdentifier(identifier) {
|
|
84
|
+
if (typeof identifier !== 'string') {
|
|
85
|
+
throw ErrorFactory.createValidationError('Invalid identifier');
|
|
86
|
+
}
|
|
87
|
+
const trimmed = identifier.trim();
|
|
88
|
+
if (trimmed.length === 0) {
|
|
89
|
+
throw ErrorFactory.createValidationError('Invalid identifier');
|
|
90
|
+
}
|
|
91
|
+
return trimmed;
|
|
92
|
+
}
|
|
93
|
+
function normalizeToken(token) {
|
|
94
|
+
if (typeof token !== 'string') {
|
|
95
|
+
throw ErrorFactory.createValidationError('Invalid token');
|
|
96
|
+
}
|
|
97
|
+
const trimmed = token.trim();
|
|
98
|
+
if (trimmed.length === 0) {
|
|
99
|
+
throw ErrorFactory.createValidationError('Invalid token');
|
|
100
|
+
}
|
|
101
|
+
return trimmed;
|
|
102
|
+
}
|
|
103
|
+
function normalizeTtlMs(ttlMs) {
|
|
104
|
+
const value = Number.isFinite(ttlMs) ? Math.trunc(ttlMs) : 0;
|
|
105
|
+
if (value <= 0) {
|
|
106
|
+
throw ErrorFactory.createConfigError('Invalid password reset TTL', { ttlMs });
|
|
107
|
+
}
|
|
108
|
+
return value;
|
|
109
|
+
}
|
|
110
|
+
function normalizeTokenBytes(tokenBytes) {
|
|
111
|
+
const value = Number.isFinite(tokenBytes) ? Math.trunc(tokenBytes) : 0;
|
|
112
|
+
if (value <= 0) {
|
|
113
|
+
throw ErrorFactory.createConfigError('Invalid password reset token bytes', { tokenBytes });
|
|
114
|
+
}
|
|
115
|
+
return value;
|
|
116
|
+
}
|
|
117
|
+
function isExpired(record, now) {
|
|
118
|
+
return now.getTime() > record.expiresAt.getTime();
|
|
119
|
+
}
|
|
120
|
+
function sha256Hex(value) {
|
|
121
|
+
return createHash('sha256').update(value).digest('hex');
|
|
122
|
+
}
|
|
123
|
+
function timingSafeEquals(a, b) {
|
|
124
|
+
if (a.length !== b.length)
|
|
125
|
+
return false;
|
|
126
|
+
let result = 0;
|
|
127
|
+
for (let i = 0; i < a.length; i++) {
|
|
128
|
+
result |= (a.codePointAt(i) ?? 0) ^ (b.codePointAt(i) ?? 0);
|
|
129
|
+
}
|
|
130
|
+
return result === 0;
|
|
131
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
export type SessionData = Record<string, unknown>;
|
|
2
|
+
export interface ISession {
|
|
3
|
+
id: string;
|
|
4
|
+
get<T = unknown>(key: string): T | undefined;
|
|
5
|
+
set(key: string, value: unknown): void;
|
|
6
|
+
has(key: string): boolean;
|
|
7
|
+
forget(key: string): void;
|
|
8
|
+
all(): SessionData;
|
|
9
|
+
clear(): void;
|
|
10
|
+
}
|
|
11
|
+
export interface ISessionManager {
|
|
12
|
+
getIdFromCookieHeader(cookieHeader: string | undefined): string | undefined;
|
|
13
|
+
getIdFromRequest(req: {
|
|
14
|
+
getHeader: (name: string) => unknown;
|
|
15
|
+
sessionId?: unknown;
|
|
16
|
+
context?: Record<string, unknown>;
|
|
17
|
+
}): string | undefined;
|
|
18
|
+
ensureSessionId(req: {
|
|
19
|
+
getHeader: (name: string) => unknown;
|
|
20
|
+
sessionId?: unknown;
|
|
21
|
+
context: Record<string, unknown>;
|
|
22
|
+
}, res: {
|
|
23
|
+
getHeader: (name: string) => unknown;
|
|
24
|
+
setHeader: (name: string, value: string | string[]) => unknown;
|
|
25
|
+
}): Promise<string>;
|
|
26
|
+
get(sessionId: string): ISession;
|
|
27
|
+
destroy(sessionId: string): void;
|
|
28
|
+
cleanup(): number;
|
|
29
|
+
}
|
|
30
|
+
export interface SessionManagerOptions {
|
|
31
|
+
cookieName?: string;
|
|
32
|
+
headerName?: string;
|
|
33
|
+
ttlMs?: number;
|
|
34
|
+
}
|
|
35
|
+
export declare const SessionManager: Readonly<{
|
|
36
|
+
create(options?: SessionManagerOptions): ISessionManager;
|
|
37
|
+
}>;
|
|
38
|
+
export default SessionManager;
|
|
39
|
+
//# sourceMappingURL=SessionManager.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"SessionManager.d.ts","sourceRoot":"","sources":["../../../src/session/SessionManager.ts"],"names":[],"mappings":"AAEA,MAAM,MAAM,WAAW,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;AAElD,MAAM,WAAW,QAAQ;IACvB,EAAE,EAAE,MAAM,CAAC;IACX,GAAG,CAAC,CAAC,GAAG,OAAO,EAAE,GAAG,EAAE,MAAM,GAAG,CAAC,GAAG,SAAS,CAAC;IAC7C,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,OAAO,GAAG,IAAI,CAAC;IACvC,GAAG,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;IAC1B,MAAM,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1B,GAAG,IAAI,WAAW,CAAC;IACnB,KAAK,IAAI,IAAI,CAAC;CACf;AAED,MAAM,WAAW,eAAe;IAC9B,qBAAqB,CAAC,YAAY,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,GAAG,SAAS,CAAC;IAC5E,gBAAgB,CAAC,GAAG,EAAE;QACpB,SAAS,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC;QACrC,SAAS,CAAC,EAAE,OAAO,CAAC;QACpB,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;KACnC,GAAG,MAAM,GAAG,SAAS,CAAC;IACvB,eAAe,CACb,GAAG,EAAE;QACH,SAAS,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC;QACrC,SAAS,CAAC,EAAE,OAAO,CAAC;QACpB,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;KAClC,EACD,GAAG,EAAE;QACH,SAAS,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC;QACrC,SAAS,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,EAAE,KAAK,OAAO,CAAC;KAChE,GACA,OAAO,CAAC,MAAM,CAAC,CAAC;IACnB,GAAG,CAAC,SAAS,EAAE,MAAM,GAAG,QAAQ,CAAC;IACjC,OAAO,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;IACjC,OAAO,IAAI,MAAM,CAAC;CACnB;AAED,MAAM,WAAW,qBAAqB;IACpC,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAyHD,eAAO,MAAM,cAAc;qBACT,qBAAqB,GAAQ,eAAe;EA2E5D,CAAC;AAEH,eAAe,cAAc,CAAC"}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { generateSecureJobId } from '../common/uuid.js';
|
|
2
|
+
const DEFAULT_OPTIONS = {
|
|
3
|
+
cookieName: 'ZIN_SESSION_ID',
|
|
4
|
+
headerName: 'x-session-id',
|
|
5
|
+
ttlMs: 7 * 24 * 60 * 60 * 1000,
|
|
6
|
+
};
|
|
7
|
+
function parseCookies(cookieHeader) {
|
|
8
|
+
const list = {};
|
|
9
|
+
if (cookieHeader.length === 0)
|
|
10
|
+
return list;
|
|
11
|
+
cookieHeader.split(';').forEach((cookie) => {
|
|
12
|
+
const parts = cookie.split('=');
|
|
13
|
+
const name = parts.shift()?.trim();
|
|
14
|
+
const value = parts.join('=');
|
|
15
|
+
if (name !== null && name !== undefined)
|
|
16
|
+
list[name] = decodeURIComponent(value);
|
|
17
|
+
});
|
|
18
|
+
return list;
|
|
19
|
+
}
|
|
20
|
+
function appendSetCookie(res, cookie) {
|
|
21
|
+
const existing = res.getHeader('Set-Cookie');
|
|
22
|
+
if (existing === undefined) {
|
|
23
|
+
res.setHeader('Set-Cookie', cookie);
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
if (Array.isArray(existing)) {
|
|
27
|
+
const existingCookies = existing.map(String);
|
|
28
|
+
res.setHeader('Set-Cookie', [...existingCookies, cookie]);
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
if (typeof existing === 'string') {
|
|
32
|
+
res.setHeader('Set-Cookie', [existing, cookie]);
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
res.setHeader('Set-Cookie', cookie);
|
|
36
|
+
}
|
|
37
|
+
function buildSessionCookie(cookieName, sessionId) {
|
|
38
|
+
// Keep this minimal; callers can override behavior later.
|
|
39
|
+
// HttpOnly prevents JS access; SameSite=Lax is a reasonable default for app sessions.
|
|
40
|
+
return `${cookieName}=${encodeURIComponent(sessionId)}; Path=/; HttpOnly; SameSite=Lax`;
|
|
41
|
+
}
|
|
42
|
+
function createSessionApi(sessions, sessionId, ttlMs) {
|
|
43
|
+
const withoutKey = (data, key) => {
|
|
44
|
+
if (!Object.prototype.hasOwnProperty.call(data, key))
|
|
45
|
+
return data;
|
|
46
|
+
const record = data;
|
|
47
|
+
const { [key]: _removed, ...rest } = record;
|
|
48
|
+
return rest;
|
|
49
|
+
};
|
|
50
|
+
const ensureStored = () => {
|
|
51
|
+
const existing = sessions.get(sessionId);
|
|
52
|
+
const now = Date.now();
|
|
53
|
+
if (existing !== undefined && existing.expiresAt > now) {
|
|
54
|
+
return existing;
|
|
55
|
+
}
|
|
56
|
+
const created = { data: {}, expiresAt: now + ttlMs };
|
|
57
|
+
sessions.set(sessionId, created);
|
|
58
|
+
return created;
|
|
59
|
+
};
|
|
60
|
+
return {
|
|
61
|
+
id: sessionId,
|
|
62
|
+
get(key) {
|
|
63
|
+
return ensureStored().data[key];
|
|
64
|
+
},
|
|
65
|
+
set(key, value) {
|
|
66
|
+
const stored = ensureStored();
|
|
67
|
+
stored.data[key] = value;
|
|
68
|
+
stored.expiresAt = Date.now() + ttlMs;
|
|
69
|
+
},
|
|
70
|
+
has(key) {
|
|
71
|
+
return Object.prototype.hasOwnProperty.call(ensureStored().data, key);
|
|
72
|
+
},
|
|
73
|
+
forget(key) {
|
|
74
|
+
const stored = ensureStored();
|
|
75
|
+
stored.data = withoutKey(stored.data, key);
|
|
76
|
+
stored.expiresAt = Date.now() + ttlMs;
|
|
77
|
+
},
|
|
78
|
+
all() {
|
|
79
|
+
return { ...ensureStored().data };
|
|
80
|
+
},
|
|
81
|
+
clear() {
|
|
82
|
+
const stored = ensureStored();
|
|
83
|
+
stored.data = {};
|
|
84
|
+
stored.expiresAt = Date.now() + ttlMs;
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
export const SessionManager = Object.freeze({
|
|
89
|
+
create(options = {}) {
|
|
90
|
+
const config = { ...DEFAULT_OPTIONS, ...options };
|
|
91
|
+
const sessions = new Map();
|
|
92
|
+
return {
|
|
93
|
+
getIdFromCookieHeader(cookieHeader) {
|
|
94
|
+
if (cookieHeader === undefined || cookieHeader.length === 0)
|
|
95
|
+
return undefined;
|
|
96
|
+
const cookies = parseCookies(cookieHeader);
|
|
97
|
+
return cookies[config.cookieName];
|
|
98
|
+
},
|
|
99
|
+
getIdFromRequest(req) {
|
|
100
|
+
const cookieHeader = req.getHeader('cookie');
|
|
101
|
+
if (typeof cookieHeader === 'string') {
|
|
102
|
+
const fromCookie = this.getIdFromCookieHeader(cookieHeader);
|
|
103
|
+
if (fromCookie !== undefined)
|
|
104
|
+
return fromCookie;
|
|
105
|
+
}
|
|
106
|
+
const fromHeader = req.getHeader(config.headerName);
|
|
107
|
+
if (typeof fromHeader === 'string' && fromHeader.length > 0)
|
|
108
|
+
return fromHeader;
|
|
109
|
+
if (typeof req.sessionId === 'string' && req.sessionId.length > 0)
|
|
110
|
+
return req.sessionId;
|
|
111
|
+
const fromContext = req.context?.['sessionId'];
|
|
112
|
+
if (typeof fromContext === 'string' && fromContext.length > 0)
|
|
113
|
+
return fromContext;
|
|
114
|
+
return undefined;
|
|
115
|
+
},
|
|
116
|
+
async ensureSessionId(req, res) {
|
|
117
|
+
const existing = this.getIdFromRequest(req);
|
|
118
|
+
const sessionId = existing ??
|
|
119
|
+
(await Promise.resolve(generateSecureJobId('SessionManager: secure crypto API not available to generate a session id')));
|
|
120
|
+
req.context['sessionId'] = sessionId;
|
|
121
|
+
// If the cookie is missing, set it.
|
|
122
|
+
const cookieHeader = req.getHeader('cookie');
|
|
123
|
+
const fromCookie = typeof cookieHeader === 'string' ? this.getIdFromCookieHeader(cookieHeader) : undefined;
|
|
124
|
+
if (fromCookie === undefined) {
|
|
125
|
+
appendSetCookie(res, buildSessionCookie(config.cookieName, sessionId));
|
|
126
|
+
}
|
|
127
|
+
return sessionId;
|
|
128
|
+
},
|
|
129
|
+
get(sessionId) {
|
|
130
|
+
return createSessionApi(sessions, sessionId, config.ttlMs);
|
|
131
|
+
},
|
|
132
|
+
destroy(sessionId) {
|
|
133
|
+
sessions.delete(sessionId);
|
|
134
|
+
},
|
|
135
|
+
cleanup() {
|
|
136
|
+
const now = Date.now();
|
|
137
|
+
let removed = 0;
|
|
138
|
+
for (const [id, stored] of sessions.entries()) {
|
|
139
|
+
if (stored.expiresAt <= now) {
|
|
140
|
+
sessions.delete(id);
|
|
141
|
+
removed++;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return removed;
|
|
145
|
+
},
|
|
146
|
+
};
|
|
147
|
+
},
|
|
148
|
+
});
|
|
149
|
+
export default SessionManager;
|