alepha 0.13.6 → 0.13.7
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/dist/api-audits/index.browser.js +116 -0
- package/dist/api-audits/index.browser.js.map +1 -0
- package/dist/api-audits/index.d.ts +1194 -0
- package/dist/api-audits/index.js +674 -0
- package/dist/api-audits/index.js.map +1 -0
- package/dist/api-notifications/index.d.ts +147 -147
- package/dist/api-parameters/index.browser.js +36 -5
- package/dist/api-parameters/index.browser.js.map +1 -1
- package/dist/api-parameters/index.d.ts +711 -33
- package/dist/api-parameters/index.js +831 -17
- package/dist/api-parameters/index.js.map +1 -1
- package/dist/api-users/index.d.ts +793 -780
- package/dist/api-users/index.js +699 -19
- package/dist/api-users/index.js.map +1 -1
- package/dist/api-verifications/index.js +2 -1
- package/dist/api-verifications/index.js.map +1 -1
- package/dist/bin/index.js +1 -0
- package/dist/bin/index.js.map +1 -1
- package/dist/cli/index.d.ts +85 -31
- package/dist/cli/index.js +205 -33
- package/dist/cli/index.js.map +1 -1
- package/dist/command/index.d.ts +67 -6
- package/dist/command/index.js +30 -3
- package/dist/command/index.js.map +1 -1
- package/dist/core/index.browser.js +241 -61
- package/dist/core/index.browser.js.map +1 -1
- package/dist/core/index.d.ts +170 -90
- package/dist/core/index.js +264 -67
- package/dist/core/index.js.map +1 -1
- package/dist/core/index.native.js +248 -65
- package/dist/core/index.native.js.map +1 -1
- package/dist/email/index.js +15 -10554
- package/dist/email/index.js.map +1 -1
- package/dist/logger/index.d.ts +4 -4
- package/dist/logger/index.js +77 -72
- package/dist/logger/index.js.map +1 -1
- package/dist/orm/index.d.ts +5 -1
- package/dist/orm/index.js +24 -7
- package/dist/orm/index.js.map +1 -1
- package/dist/queue/index.d.ts +4 -4
- package/dist/scheduler/index.d.ts +6 -6
- package/dist/server/index.d.ts +10 -1
- package/dist/server/index.js +20 -6
- package/dist/server/index.js.map +1 -1
- package/dist/server-auth/index.d.ts +163 -152
- package/dist/server-auth/index.js +40 -10
- package/dist/server-auth/index.js.map +1 -1
- package/dist/server-cookies/index.js +5 -1
- package/dist/server-cookies/index.js.map +1 -1
- package/dist/server-links/index.d.ts +33 -33
- package/dist/server-security/index.d.ts +9 -9
- package/dist/thread/index.js +2 -2
- package/dist/thread/index.js.map +1 -1
- package/dist/vite/index.d.ts +2 -2
- package/dist/vite/index.js +102 -45
- package/dist/vite/index.js.map +1 -1
- package/dist/websocket/index.browser.js +3 -3
- package/dist/websocket/index.browser.js.map +1 -1
- package/dist/websocket/index.js +4 -4
- package/dist/websocket/index.js.map +1 -1
- package/package.json +14 -9
- package/src/api-audits/controllers/AuditController.ts +186 -0
- package/src/api-audits/entities/audits.ts +132 -0
- package/src/api-audits/index.browser.ts +18 -0
- package/src/api-audits/index.ts +58 -0
- package/src/api-audits/primitives/$audit.ts +159 -0
- package/src/api-audits/schemas/auditQuerySchema.ts +23 -0
- package/src/api-audits/schemas/auditResourceSchema.ts +9 -0
- package/src/api-audits/schemas/createAuditSchema.ts +27 -0
- package/src/api-audits/services/AuditService.ts +412 -0
- package/src/api-parameters/controllers/ConfigController.ts +324 -0
- package/src/api-parameters/entities/parameters.ts +93 -10
- package/src/api-parameters/index.ts +43 -4
- package/src/api-parameters/primitives/$config.ts +291 -19
- package/src/api-parameters/schedulers/ConfigActivationScheduler.ts +30 -0
- package/src/api-parameters/services/ConfigStore.ts +491 -0
- package/src/api-users/atoms/realmAuthSettingsAtom.ts +19 -0
- package/src/api-users/controllers/UserRealmController.ts +0 -2
- package/src/api-users/index.ts +2 -0
- package/src/api-users/primitives/$userRealm.ts +18 -3
- package/src/api-users/providers/UserRealmProvider.ts +6 -3
- package/src/api-users/services/RegistrationService.ts +2 -1
- package/src/api-users/services/SessionService.ts +4 -0
- package/src/api-users/services/UserService.ts +3 -0
- package/src/api-verifications/index.ts +7 -1
- package/src/bin/index.ts +1 -0
- package/src/cli/assets/biomeJson.ts +1 -1
- package/src/cli/assets/dummySpecTs.ts +7 -0
- package/src/cli/assets/editorconfig.ts +13 -0
- package/src/cli/assets/mainTs.ts +14 -0
- package/src/cli/commands/BiomeCommands.ts +2 -0
- package/src/cli/commands/CoreCommands.ts +28 -9
- package/src/cli/commands/VerifyCommands.ts +2 -1
- package/src/cli/commands/ViteCommands.ts +8 -9
- package/src/cli/services/AlephaCliUtils.ts +214 -23
- package/src/command/helpers/Asker.ts +0 -1
- package/src/command/primitives/$command.ts +67 -0
- package/src/command/providers/CliProvider.ts +39 -8
- package/src/core/Alepha.ts +40 -30
- package/src/core/helpers/jsonSchemaToTypeBox.ts +307 -0
- package/src/core/index.shared.ts +1 -0
- package/src/core/index.ts +30 -3
- package/src/core/providers/EventManager.ts +1 -1
- package/src/core/providers/StateManager.ts +23 -12
- package/src/core/providers/TypeProvider.ts +26 -34
- package/src/logger/index.ts +8 -6
- package/src/logger/primitives/$logger.ts +1 -1
- package/src/logger/providers/{SimpleFormatterProvider.ts → PrettyFormatterProvider.ts} +10 -1
- package/src/orm/index.ts +6 -0
- package/src/orm/services/PgRelationManager.ts +2 -2
- package/src/orm/services/PostgresModelBuilder.ts +11 -7
- package/src/orm/services/Repository.ts +16 -7
- package/src/orm/services/SqliteModelBuilder.ts +10 -0
- package/src/server/index.ts +6 -0
- package/src/server/primitives/$action.ts +10 -1
- package/src/server/providers/ServerBodyParserProvider.ts +11 -5
- package/src/server/providers/ServerRouterProvider.ts +13 -7
- package/src/server-auth/primitives/$auth.ts +7 -0
- package/src/server-auth/providers/ServerAuthProvider.ts +51 -8
- package/src/server-cookies/index.ts +2 -1
- package/src/thread/primitives/$thread.ts +2 -2
- package/src/vite/index.ts +0 -2
- package/src/vite/tasks/buildServer.ts +3 -4
- package/src/vite/tasks/generateCloudflare.ts +35 -19
- package/src/vite/tasks/generateDocker.ts +18 -4
- package/src/vite/tasks/generateSitemap.ts +5 -7
- package/src/vite/tasks/generateVercel.ts +76 -41
- package/src/vite/tasks/runAlepha.ts +16 -1
- package/src/websocket/providers/NodeWebSocketServerProvider.ts +3 -11
- package/src/websocket/services/WebSocketClient.ts +3 -3
- package/dist/cli/dist-BlfFtOk2.js +0 -2770
- package/dist/cli/dist-BlfFtOk2.js.map +0 -1
- package/src/api-parameters/controllers/ParameterController.ts +0 -45
- package/src/api-parameters/services/ParameterStore.ts +0 -23
|
@@ -0,0 +1,491 @@
|
|
|
1
|
+
import { $inject, type Static, type TObject, t } from "alepha";
|
|
2
|
+
import { DateTimeProvider } from "alepha/datetime";
|
|
3
|
+
import { $logger } from "alepha/logger";
|
|
4
|
+
import { $repository } from "alepha/orm";
|
|
5
|
+
import { $topic } from "alepha/topic";
|
|
6
|
+
import {
|
|
7
|
+
type Parameter,
|
|
8
|
+
type ParameterStatus,
|
|
9
|
+
parameters,
|
|
10
|
+
} from "../entities/parameters.ts";
|
|
11
|
+
import type { ConfigPrimitive } from "../primitives/$config.ts";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Payload for config sync events across instances.
|
|
15
|
+
*/
|
|
16
|
+
export interface ConfigSyncPayload {
|
|
17
|
+
name: string;
|
|
18
|
+
version: number;
|
|
19
|
+
content: unknown;
|
|
20
|
+
status: ParameterStatus;
|
|
21
|
+
instanceId: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* ConfigStore manages versioned configuration persistence and synchronization.
|
|
26
|
+
*
|
|
27
|
+
* Features:
|
|
28
|
+
* - Stores all config versions in PostgreSQL
|
|
29
|
+
* - Manages status transitions (future → next → current → expired)
|
|
30
|
+
* - Provides cross-instance sync via topic
|
|
31
|
+
* - Supports schema migrations via hash comparison
|
|
32
|
+
* - Auto-activates scheduled configs
|
|
33
|
+
*/
|
|
34
|
+
export class ConfigStore {
|
|
35
|
+
protected readonly log = $logger();
|
|
36
|
+
protected readonly dateTimeProvider = $inject(DateTimeProvider);
|
|
37
|
+
protected readonly repo = $repository(parameters);
|
|
38
|
+
|
|
39
|
+
/** Unique identifier for this instance (to avoid self-updates) */
|
|
40
|
+
protected readonly instanceId = crypto.randomUUID();
|
|
41
|
+
|
|
42
|
+
/** In-memory cache of registered configs */
|
|
43
|
+
protected readonly configs = new Map<string, ConfigPrimitive<any>>();
|
|
44
|
+
|
|
45
|
+
/** Topic for cross-instance synchronization */
|
|
46
|
+
public readonly syncTopic = $topic({
|
|
47
|
+
name: "config:sync",
|
|
48
|
+
schema: {
|
|
49
|
+
payload: t.object({
|
|
50
|
+
name: t.text(),
|
|
51
|
+
version: t.integer(),
|
|
52
|
+
content: t.json(),
|
|
53
|
+
status: t.enum(["expired", "current", "next", "future"]),
|
|
54
|
+
instanceId: t.text(),
|
|
55
|
+
}),
|
|
56
|
+
},
|
|
57
|
+
handler: async ({ payload }) => {
|
|
58
|
+
await this.handleSyncMessage(payload as ConfigSyncPayload);
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Register a config primitive with the store.
|
|
64
|
+
*/
|
|
65
|
+
public register(config: ConfigPrimitive<any>): void {
|
|
66
|
+
this.configs.set(config.name, config);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Load the current config value from database.
|
|
71
|
+
* Returns the current or next version if no current exists.
|
|
72
|
+
*/
|
|
73
|
+
public async load<T extends TObject>(
|
|
74
|
+
name: string,
|
|
75
|
+
): Promise<Static<T> | null> {
|
|
76
|
+
// First try to get CURRENT
|
|
77
|
+
const all = await this.repo.findMany({
|
|
78
|
+
where: { name },
|
|
79
|
+
orderBy: { column: "version", direction: "desc" },
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
let param = all.find((p) => p.status === "current");
|
|
83
|
+
|
|
84
|
+
// If no current, get NEXT (will become current)
|
|
85
|
+
if (!param) {
|
|
86
|
+
param = all
|
|
87
|
+
.filter((p) => p.status === "next")
|
|
88
|
+
.sort((a, b) => {
|
|
89
|
+
return (
|
|
90
|
+
new Date(a.activationDate).getTime() -
|
|
91
|
+
new Date(b.activationDate).getTime()
|
|
92
|
+
);
|
|
93
|
+
})[0];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return param?.content as Static<T> | null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Save a new config version.
|
|
101
|
+
*
|
|
102
|
+
* @param name - Config name (e.g., "app.features.flags")
|
|
103
|
+
* @param content - New config content
|
|
104
|
+
* @param schemaHash - Hash of the schema for migration detection
|
|
105
|
+
* @param options - Additional options (activation date, creator info, etc.)
|
|
106
|
+
*/
|
|
107
|
+
public async save<T extends TObject>(
|
|
108
|
+
name: string,
|
|
109
|
+
content: Static<T>,
|
|
110
|
+
schemaHash: string,
|
|
111
|
+
options: SaveConfigOptions = {},
|
|
112
|
+
): Promise<Parameter> {
|
|
113
|
+
const now = this.dateTimeProvider.now().toDate();
|
|
114
|
+
const activationDate = options.activationDate ?? now;
|
|
115
|
+
const isImmediate = activationDate <= now;
|
|
116
|
+
|
|
117
|
+
// Get current version number for this config
|
|
118
|
+
const versions = await this.repo.findMany({
|
|
119
|
+
where: { name },
|
|
120
|
+
orderBy: { column: "version", direction: "desc" },
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
const latestVersion = versions[0];
|
|
124
|
+
const newVersion = (latestVersion?.version ?? 0) + 1;
|
|
125
|
+
|
|
126
|
+
// Determine initial status
|
|
127
|
+
let status: ParameterStatus = "future";
|
|
128
|
+
if (isImmediate) {
|
|
129
|
+
status = "current";
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Get previous content for rollback reference
|
|
133
|
+
const currentConfig = versions.find((v) => v.status === "current");
|
|
134
|
+
const previousContent = currentConfig?.content;
|
|
135
|
+
|
|
136
|
+
// Check for schema migration
|
|
137
|
+
let migrationLog: string | undefined;
|
|
138
|
+
if (latestVersion && latestVersion.schemaHash !== schemaHash) {
|
|
139
|
+
migrationLog = `Schema changed from ${latestVersion.schemaHash} to ${schemaHash} at version ${newVersion}`;
|
|
140
|
+
this.log.info("Config schema migration detected", { name, migrationLog });
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// If immediate activation, expire current and transition next to current
|
|
144
|
+
if (isImmediate) {
|
|
145
|
+
await this.transitionStatuses(name, now);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Insert new version - convert Date to ISO string for datetime fields
|
|
149
|
+
const inserted = await this.repo.create({
|
|
150
|
+
name,
|
|
151
|
+
content: content as Record<string, unknown>,
|
|
152
|
+
schemaHash,
|
|
153
|
+
status,
|
|
154
|
+
activationDate: activationDate.toISOString(),
|
|
155
|
+
version: newVersion,
|
|
156
|
+
changeDescription: options.changeDescription,
|
|
157
|
+
tags: options.tags,
|
|
158
|
+
creatorId: options.creatorId,
|
|
159
|
+
creatorName: options.creatorName,
|
|
160
|
+
previousContent: previousContent as Record<string, unknown> | undefined,
|
|
161
|
+
migrationLog,
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
// Recalculate statuses after insert
|
|
165
|
+
await this.recalculateStatuses(name);
|
|
166
|
+
|
|
167
|
+
// Publish sync event
|
|
168
|
+
await this.publishSync(name, newVersion, content, status);
|
|
169
|
+
|
|
170
|
+
this.log.info("Config saved", { name, version: newVersion, status });
|
|
171
|
+
|
|
172
|
+
return inserted;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Get all versions of a config.
|
|
177
|
+
*/
|
|
178
|
+
public async getHistory(name: string): Promise<Parameter[]> {
|
|
179
|
+
return this.repo.findMany({
|
|
180
|
+
where: { name },
|
|
181
|
+
orderBy: { column: "version", direction: "desc" },
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Get a specific version of a config.
|
|
187
|
+
*/
|
|
188
|
+
public async getVersion(
|
|
189
|
+
name: string,
|
|
190
|
+
version: number,
|
|
191
|
+
): Promise<Parameter | null> {
|
|
192
|
+
const versions = await this.repo.findMany({
|
|
193
|
+
where: { name, version },
|
|
194
|
+
});
|
|
195
|
+
return versions[0] ?? null;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Rollback to a previous version by creating a new version with old content.
|
|
200
|
+
*/
|
|
201
|
+
public async rollback(
|
|
202
|
+
name: string,
|
|
203
|
+
targetVersion: number,
|
|
204
|
+
options: SaveConfigOptions = {},
|
|
205
|
+
): Promise<Parameter> {
|
|
206
|
+
const target = await this.getVersion(name, targetVersion);
|
|
207
|
+
|
|
208
|
+
if (!target) {
|
|
209
|
+
throw new Error(`Config version not found: ${name}@${targetVersion}`);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return this.save(
|
|
213
|
+
name,
|
|
214
|
+
target.content as Static<TObject>,
|
|
215
|
+
target.schemaHash,
|
|
216
|
+
{
|
|
217
|
+
...options,
|
|
218
|
+
changeDescription:
|
|
219
|
+
options.changeDescription ?? `Rollback to version ${targetVersion}`,
|
|
220
|
+
},
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Get all configs by status.
|
|
226
|
+
*/
|
|
227
|
+
public async getByStatus(status: ParameterStatus): Promise<Parameter[]> {
|
|
228
|
+
return this.repo.findMany({
|
|
229
|
+
where: { status },
|
|
230
|
+
orderBy: { column: "name", direction: "asc" },
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Get current config value with fallback to default from registered primitive.
|
|
236
|
+
* Returns the in-memory current value which may be the default if never saved.
|
|
237
|
+
*/
|
|
238
|
+
public getCurrentValue(
|
|
239
|
+
name: string,
|
|
240
|
+
): { content: unknown; isDefault: boolean } | null {
|
|
241
|
+
const config = this.configs.get(name);
|
|
242
|
+
if (!config) {
|
|
243
|
+
return null;
|
|
244
|
+
}
|
|
245
|
+
return {
|
|
246
|
+
content: config.current,
|
|
247
|
+
isDefault: true, // Will be updated after checking history
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Get config info including current value with default fallback.
|
|
253
|
+
*/
|
|
254
|
+
public async getCurrentWithDefault(name: string): Promise<{
|
|
255
|
+
current: Parameter | null;
|
|
256
|
+
next: Parameter | null;
|
|
257
|
+
defaultValue: unknown | null;
|
|
258
|
+
currentValue: unknown | null;
|
|
259
|
+
schema: TObject | null;
|
|
260
|
+
}> {
|
|
261
|
+
const history = await this.getHistory(name);
|
|
262
|
+
const current = history.find((v) => v.status === "current") ?? null;
|
|
263
|
+
const next = history.find((v) => v.status === "next") ?? null;
|
|
264
|
+
|
|
265
|
+
// Get default and current from registered primitive
|
|
266
|
+
const config = this.configs.get(name);
|
|
267
|
+
const defaultValue = config?.options.default ?? null;
|
|
268
|
+
const currentValue = config?.current ?? null;
|
|
269
|
+
const schema = config?.schema ?? null;
|
|
270
|
+
|
|
271
|
+
return { current, next, defaultValue, currentValue, schema };
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Get all unique config names (for tree view).
|
|
276
|
+
*/
|
|
277
|
+
public async getConfigNames(): Promise<string[]> {
|
|
278
|
+
const results = await this.repo.findMany({
|
|
279
|
+
orderBy: { column: "name", direction: "asc" },
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
const names = new Set<string>();
|
|
283
|
+
for (const r of results) {
|
|
284
|
+
names.add(r.name);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
return Array.from(names);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Build a tree structure from config names for UI.
|
|
292
|
+
* Includes both database configs and registered (but not yet saved) configs.
|
|
293
|
+
*/
|
|
294
|
+
public async getConfigTree(): Promise<ConfigTreeNode[]> {
|
|
295
|
+
const dbNames = await this.getConfigNames();
|
|
296
|
+
const registeredNames = Array.from(this.configs.keys());
|
|
297
|
+
const allNames = [...new Set([...dbNames, ...registeredNames])].sort();
|
|
298
|
+
return this.buildTree(allNames);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Check and activate scheduled configs that are due.
|
|
303
|
+
* Should be called periodically (e.g., via scheduler).
|
|
304
|
+
*/
|
|
305
|
+
public async activateScheduledConfigs(): Promise<void> {
|
|
306
|
+
const now = this.dateTimeProvider.now().toDate();
|
|
307
|
+
|
|
308
|
+
// Find all NEXT configs that should be activated
|
|
309
|
+
const dueConfigs = await this.repo.findMany({
|
|
310
|
+
where: { status: "next" },
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
for (const config of dueConfigs) {
|
|
314
|
+
if (new Date(config.activationDate) <= now) {
|
|
315
|
+
await this.transitionStatuses(config.name, now);
|
|
316
|
+
await this.recalculateStatuses(config.name);
|
|
317
|
+
|
|
318
|
+
// Notify registered config primitives
|
|
319
|
+
const primitive = this.configs.get(config.name);
|
|
320
|
+
if (primitive) {
|
|
321
|
+
await primitive.reload();
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// Publish sync
|
|
325
|
+
await this.publishSync(
|
|
326
|
+
config.name,
|
|
327
|
+
config.version,
|
|
328
|
+
config.content,
|
|
329
|
+
"current",
|
|
330
|
+
);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Transition config statuses when a new current is activated.
|
|
337
|
+
*/
|
|
338
|
+
protected async transitionStatuses(name: string, now: Date): Promise<void> {
|
|
339
|
+
// Find current configs and expire them
|
|
340
|
+
const currentConfigs = await this.repo.findMany({
|
|
341
|
+
where: { name, status: "current" },
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
for (const config of currentConfigs) {
|
|
345
|
+
await this.repo.updateById(config.id, {
|
|
346
|
+
status: "expired",
|
|
347
|
+
expiredAt: now.toISOString(),
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Recalculate statuses based on activation dates.
|
|
354
|
+
*/
|
|
355
|
+
protected async recalculateStatuses(name: string): Promise<void> {
|
|
356
|
+
const now = this.dateTimeProvider.now().toDate();
|
|
357
|
+
|
|
358
|
+
// Get all versions ordered by activation date
|
|
359
|
+
const versions = await this.repo.findMany({
|
|
360
|
+
where: { name },
|
|
361
|
+
orderBy: { column: "activationDate", direction: "asc" },
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
const nonExpired = versions.filter(
|
|
365
|
+
(v: Parameter) => v.status !== "expired",
|
|
366
|
+
);
|
|
367
|
+
|
|
368
|
+
// Find which should be current (latest activated)
|
|
369
|
+
const shouldBeCurrent = nonExpired
|
|
370
|
+
.filter((v: Parameter) => new Date(v.activationDate) <= now)
|
|
371
|
+
.pop();
|
|
372
|
+
|
|
373
|
+
// Find which should be next (closest future)
|
|
374
|
+
const futureVersions = nonExpired.filter(
|
|
375
|
+
(v: Parameter) => new Date(v.activationDate) > now,
|
|
376
|
+
);
|
|
377
|
+
const shouldBeNext = futureVersions[0];
|
|
378
|
+
|
|
379
|
+
for (const v of nonExpired) {
|
|
380
|
+
let newStatus: ParameterStatus;
|
|
381
|
+
|
|
382
|
+
if (shouldBeCurrent && v.id === shouldBeCurrent.id) {
|
|
383
|
+
newStatus = "current";
|
|
384
|
+
} else if (shouldBeNext && v.id === shouldBeNext.id) {
|
|
385
|
+
newStatus = "next";
|
|
386
|
+
} else if (new Date(v.activationDate) > now) {
|
|
387
|
+
newStatus = "future";
|
|
388
|
+
} else {
|
|
389
|
+
newStatus = "expired";
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
if (v.status !== newStatus) {
|
|
393
|
+
await this.repo.updateById(v.id, {
|
|
394
|
+
status: newStatus,
|
|
395
|
+
expiredAt: newStatus === "expired" ? now.toISOString() : undefined,
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* Publish sync event to other instances.
|
|
403
|
+
*/
|
|
404
|
+
protected async publishSync(
|
|
405
|
+
name: string,
|
|
406
|
+
version: number,
|
|
407
|
+
content: unknown,
|
|
408
|
+
status: ParameterStatus,
|
|
409
|
+
): Promise<void> {
|
|
410
|
+
await this.syncTopic.publish({
|
|
411
|
+
name,
|
|
412
|
+
version,
|
|
413
|
+
content: content as Record<string, unknown>,
|
|
414
|
+
status,
|
|
415
|
+
instanceId: this.instanceId,
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* Handle incoming sync message from other instances.
|
|
421
|
+
*/
|
|
422
|
+
protected async handleSyncMessage(payload: ConfigSyncPayload): Promise<void> {
|
|
423
|
+
// Ignore messages from self
|
|
424
|
+
if (payload.instanceId === this.instanceId) {
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
const config = this.configs.get(payload.name);
|
|
429
|
+
if (!config) {
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// Update config with skipEvents to avoid infinite loop
|
|
434
|
+
if (payload.status === "current") {
|
|
435
|
+
await config.updateFromSync(payload.content);
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* Build tree structure from dot-notation names.
|
|
441
|
+
*/
|
|
442
|
+
protected buildTree(names: string[]): ConfigTreeNode[] {
|
|
443
|
+
const root: ConfigTreeNode[] = [];
|
|
444
|
+
|
|
445
|
+
for (const name of names) {
|
|
446
|
+
const parts = name.split(".");
|
|
447
|
+
let currentLevel = root;
|
|
448
|
+
|
|
449
|
+
for (let i = 0; i < parts.length; i++) {
|
|
450
|
+
const part = parts[i];
|
|
451
|
+
const isLeaf = i === parts.length - 1;
|
|
452
|
+
const path = parts.slice(0, i + 1).join(".");
|
|
453
|
+
|
|
454
|
+
let existing = currentLevel.find((n) => n.name === part);
|
|
455
|
+
|
|
456
|
+
if (!existing) {
|
|
457
|
+
existing = {
|
|
458
|
+
name: part,
|
|
459
|
+
path,
|
|
460
|
+
isLeaf,
|
|
461
|
+
children: [],
|
|
462
|
+
};
|
|
463
|
+
currentLevel.push(existing);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
if (isLeaf) {
|
|
467
|
+
existing.isLeaf = true;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
currentLevel = existing.children;
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
return root;
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
export interface SaveConfigOptions {
|
|
479
|
+
activationDate?: Date;
|
|
480
|
+
changeDescription?: string;
|
|
481
|
+
tags?: string[];
|
|
482
|
+
creatorId?: string;
|
|
483
|
+
creatorName?: string;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
export interface ConfigTreeNode {
|
|
487
|
+
name: string;
|
|
488
|
+
path: string;
|
|
489
|
+
isLeaf: boolean;
|
|
490
|
+
children: ConfigTreeNode[];
|
|
491
|
+
}
|
|
@@ -3,6 +3,25 @@ import { $atom, type Static, t } from "alepha";
|
|
|
3
3
|
export const realmAuthSettingsAtom = $atom({
|
|
4
4
|
name: "alepha.api.users.realmAuthSettings",
|
|
5
5
|
schema: t.object({
|
|
6
|
+
// Branding and display settings
|
|
7
|
+
displayName: t.optional(
|
|
8
|
+
t.string({
|
|
9
|
+
description:
|
|
10
|
+
"Display name shown on auth pages (e.g., 'Customer Portal')",
|
|
11
|
+
}),
|
|
12
|
+
),
|
|
13
|
+
description: t.optional(
|
|
14
|
+
t.string({
|
|
15
|
+
description: "Description shown on auth pages",
|
|
16
|
+
}),
|
|
17
|
+
),
|
|
18
|
+
logoUrl: t.optional(
|
|
19
|
+
t.string({
|
|
20
|
+
description: "Logo URL for auth pages",
|
|
21
|
+
}),
|
|
22
|
+
),
|
|
23
|
+
|
|
24
|
+
// Auth settings
|
|
6
25
|
registrationAllowed: t.boolean({
|
|
7
26
|
description: "Enable user self-registration",
|
|
8
27
|
}),
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { $inject, t } from "alepha";
|
|
2
|
-
import { CryptoProvider } from "alepha/security";
|
|
3
2
|
import { $action } from "alepha/server";
|
|
4
3
|
import { ServerAuthProvider } from "alepha/server/auth";
|
|
5
4
|
import { UserRealmProvider } from "../providers/UserRealmProvider.ts";
|
|
@@ -13,7 +12,6 @@ export class UserRealmController {
|
|
|
13
12
|
protected readonly url = "/realms";
|
|
14
13
|
protected readonly userRealmProvider = $inject(UserRealmProvider);
|
|
15
14
|
protected readonly serverAuthProvider = $inject(ServerAuthProvider);
|
|
16
|
-
protected readonly cryptoProvider = $inject(CryptoProvider);
|
|
17
15
|
|
|
18
16
|
/**
|
|
19
17
|
* Get realm configuration settings.
|
package/src/api-users/index.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { IdentityController } from "./controllers/IdentityController.ts";
|
|
|
6
6
|
import { SessionController } from "./controllers/SessionController.ts";
|
|
7
7
|
import { UserController } from "./controllers/UserController.ts";
|
|
8
8
|
import { UserRealmController } from "./controllers/UserRealmController.ts";
|
|
9
|
+
import { UserNotifications } from "./notifications/UserNotifications.ts";
|
|
9
10
|
import { UserRealmProvider } from "./providers/UserRealmProvider.ts";
|
|
10
11
|
import { CredentialService } from "./services/CredentialService.ts";
|
|
11
12
|
import { IdentityService } from "./services/IdentityService.ts";
|
|
@@ -76,5 +77,6 @@ export const AlephaApiUsers = $module({
|
|
|
76
77
|
SessionController,
|
|
77
78
|
IdentityController,
|
|
78
79
|
UserRealmController,
|
|
80
|
+
UserNotifications,
|
|
79
81
|
],
|
|
80
82
|
});
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { $context } from "alepha";
|
|
2
2
|
import { AlephaApiFiles } from "alepha/api/files";
|
|
3
|
+
import { AlephaApiJobs } from "alepha/api/jobs";
|
|
3
4
|
import type { Repository } from "alepha/orm";
|
|
4
5
|
import {
|
|
5
6
|
$realm,
|
|
@@ -17,6 +18,7 @@ import {
|
|
|
17
18
|
type WithLinkFn,
|
|
18
19
|
type WithLoginFn,
|
|
19
20
|
} from "alepha/server/auth";
|
|
21
|
+
import { AlephaApiAudits } from "../../api-audits/index.ts";
|
|
20
22
|
import type { RealmAuthSettings } from "../atoms/realmAuthSettingsAtom.ts";
|
|
21
23
|
import type { identities } from "../entities/identities.ts";
|
|
22
24
|
import type { sessions } from "../entities/sessions.ts";
|
|
@@ -52,12 +54,17 @@ export const $userRealm = (
|
|
|
52
54
|
const userRealm = userRealmProvider.register(name, options);
|
|
53
55
|
|
|
54
56
|
if (options.modules?.audits) {
|
|
57
|
+
alepha.with(AlephaApiAudits);
|
|
55
58
|
}
|
|
56
59
|
|
|
57
60
|
if (options.modules?.files) {
|
|
58
61
|
alepha.with(AlephaApiFiles);
|
|
59
62
|
}
|
|
60
63
|
|
|
64
|
+
if (options.modules?.jobs) {
|
|
65
|
+
alepha.with(AlephaApiJobs);
|
|
66
|
+
}
|
|
67
|
+
|
|
61
68
|
const realm: UserRealmPrimitive = $realm({
|
|
62
69
|
...options.realm,
|
|
63
70
|
name,
|
|
@@ -103,12 +110,19 @@ export const $userRealm = (
|
|
|
103
110
|
});
|
|
104
111
|
|
|
105
112
|
realm.link = (name: string) => {
|
|
106
|
-
return (ctx: LinkAccountOptions) =>
|
|
113
|
+
return (ctx: LinkAccountOptions) =>
|
|
114
|
+
sessionService.link(name, ctx.user, realm.name);
|
|
107
115
|
};
|
|
108
116
|
|
|
109
117
|
realm.login = (name: string) => {
|
|
110
|
-
return (credentials: Credentials) =>
|
|
111
|
-
sessionService.login(
|
|
118
|
+
return (credentials: Credentials) => {
|
|
119
|
+
return sessionService.login(
|
|
120
|
+
name,
|
|
121
|
+
credentials.username,
|
|
122
|
+
credentials.password,
|
|
123
|
+
realm.name,
|
|
124
|
+
);
|
|
125
|
+
};
|
|
112
126
|
};
|
|
113
127
|
|
|
114
128
|
const identities = options.identities ?? {
|
|
@@ -175,5 +189,6 @@ export interface UserRealmOptions {
|
|
|
175
189
|
modules?: {
|
|
176
190
|
files?: boolean;
|
|
177
191
|
audits?: boolean;
|
|
192
|
+
jobs?: boolean;
|
|
178
193
|
};
|
|
179
194
|
}
|
|
@@ -81,9 +81,12 @@ export class UserRealmProvider {
|
|
|
81
81
|
|
|
82
82
|
if (!realm) {
|
|
83
83
|
// Auto-register default realm for backward compatibility
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
84
|
+
const realms = Array.from(this.realms.values());
|
|
85
|
+
const firstRealm = realms[0];
|
|
86
|
+
if (userRealmName === DEFAULT_USER_REALM_NAME && firstRealm) {
|
|
87
|
+
realm = firstRealm;
|
|
88
|
+
} else if (this.alepha.isTest()) {
|
|
89
|
+
realm = this.register(userRealmName); // Auto-create default realm in tests
|
|
87
90
|
} else {
|
|
88
91
|
throw new AlephaError(
|
|
89
92
|
`Missing user realm '${userRealmName}', please declare $userRealm in your application.`,
|
|
@@ -248,6 +248,7 @@ export class RegistrationService {
|
|
|
248
248
|
|
|
249
249
|
// Create the user
|
|
250
250
|
const user = await userRepository.create({
|
|
251
|
+
realm: userRealmName,
|
|
251
252
|
username: intent.data.username,
|
|
252
253
|
email: intent.data.email,
|
|
253
254
|
phoneNumber: intent.data.phoneNumber,
|
|
@@ -340,7 +341,7 @@ export class RegistrationService {
|
|
|
340
341
|
this.log.debug("Email verification code sent", { email });
|
|
341
342
|
} catch (error) {
|
|
342
343
|
// Silent fail - verification service may have rate limiting
|
|
343
|
-
this.log.warn("Failed to send email verification code",
|
|
344
|
+
this.log.warn("Failed to send email verification code", error);
|
|
344
345
|
}
|
|
345
346
|
}
|
|
346
347
|
|
|
@@ -77,6 +77,7 @@ export class SessionService {
|
|
|
77
77
|
this.log.warn("Invalid login identifier format", {
|
|
78
78
|
provider,
|
|
79
79
|
username,
|
|
80
|
+
realm: name,
|
|
80
81
|
});
|
|
81
82
|
throw new InvalidCredentialsError();
|
|
82
83
|
}
|
|
@@ -86,6 +87,7 @@ export class SessionService {
|
|
|
86
87
|
this.log.warn("User not found during login attempt", {
|
|
87
88
|
provider,
|
|
88
89
|
username,
|
|
90
|
+
realm: name,
|
|
89
91
|
});
|
|
90
92
|
throw new InvalidCredentialsError();
|
|
91
93
|
}
|
|
@@ -103,6 +105,7 @@ export class SessionService {
|
|
|
103
105
|
provider,
|
|
104
106
|
username,
|
|
105
107
|
identityId: identity.id,
|
|
108
|
+
realm: name,
|
|
106
109
|
});
|
|
107
110
|
throw new InvalidCredentialsError();
|
|
108
111
|
}
|
|
@@ -116,6 +119,7 @@ export class SessionService {
|
|
|
116
119
|
this.log.warn("Invalid password during login attempt", {
|
|
117
120
|
provider,
|
|
118
121
|
username,
|
|
122
|
+
realm: name,
|
|
119
123
|
});
|
|
120
124
|
throw new InvalidCredentialsError();
|
|
121
125
|
}
|
|
@@ -243,6 +243,8 @@ export class UserService {
|
|
|
243
243
|
userRealmName,
|
|
244
244
|
});
|
|
245
245
|
|
|
246
|
+
const realm = this.userRealmProvider.getRealm(userRealmName);
|
|
247
|
+
|
|
246
248
|
// TODO: one query instead of 3
|
|
247
249
|
|
|
248
250
|
// Check for existing user based on provided unique fields
|
|
@@ -290,6 +292,7 @@ export class UserService {
|
|
|
290
292
|
const user = await this.users(userRealmName).create({
|
|
291
293
|
...data,
|
|
292
294
|
roles: data.roles ?? ["user"], // TODO: Default roles from realm settings
|
|
295
|
+
realm: realm.name,
|
|
293
296
|
});
|
|
294
297
|
|
|
295
298
|
this.log.info("User created", {
|