@jskit-ai/users-core 0.1.105 → 0.1.107

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.
@@ -1,7 +1,7 @@
1
1
  export default Object.freeze({
2
2
  packageVersion: 1,
3
3
  packageId: "@jskit-ai/users-core",
4
- version: "0.1.105",
4
+ version: "0.1.107",
5
5
  kind: "runtime",
6
6
  description: "Users/account runtime plus HTTP routes for account features.",
7
7
  dependsOn: [
@@ -143,16 +143,16 @@ export default Object.freeze({
143
143
  mutations: {
144
144
  dependencies: {
145
145
  runtime: {
146
- "@jskit-ai/auth-core": "0.1.94",
147
- "@jskit-ai/crud-core": "0.1.103",
148
- "@jskit-ai/database-runtime": "0.1.95",
149
- "@jskit-ai/http-runtime": "0.1.94",
150
- "@jskit-ai/json-rest-api-core": "0.1.40",
151
- "@jskit-ai/kernel": "0.1.95",
152
- "@jskit-ai/resource-core": "0.1.40",
153
- "@jskit-ai/resource-crud-core": "0.1.40",
146
+ "@jskit-ai/auth-core": "0.1.96",
147
+ "@jskit-ai/crud-core": "0.1.105",
148
+ "@jskit-ai/database-runtime": "0.1.97",
149
+ "@jskit-ai/http-runtime": "0.1.96",
150
+ "@jskit-ai/json-rest-api-core": "0.1.42",
151
+ "@jskit-ai/kernel": "0.1.98",
152
+ "@jskit-ai/resource-core": "0.1.42",
153
+ "@jskit-ai/resource-crud-core": "0.1.42",
154
154
  "@local/users": "file:packages/users",
155
- "@jskit-ai/uploads-runtime": "0.1.73"
155
+ "@jskit-ai/uploads-runtime": "0.1.75"
156
156
  },
157
157
  dev: {}
158
158
  },
package/package.json CHANGED
@@ -1,24 +1,25 @@
1
1
  {
2
2
  "name": "@jskit-ai/users-core",
3
- "version": "0.1.105",
3
+ "version": "0.1.107",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "test": "node --test"
7
7
  },
8
8
  "exports": {
9
+ "./server/previewUserProvisioning": "./src/server/previewUserProvisioning.js",
9
10
  "./server/profileSyncLifecycleContributorRegistry": "./src/server/profileSyncLifecycleContributorRegistry.js",
10
11
  "./shared/resources/userProfileResource": "./src/shared/resources/userProfileResource.js",
11
12
  "./shared/resources/userSettingsResource": "./src/shared/resources/userSettingsResource.js"
12
13
  },
13
14
  "dependencies": {
14
- "@jskit-ai/auth-core": "0.1.94",
15
- "@jskit-ai/database-runtime": "0.1.95",
16
- "@jskit-ai/http-runtime": "0.1.94",
17
- "@jskit-ai/json-rest-api-core": "0.1.40",
18
- "@jskit-ai/kernel": "0.1.95",
19
- "@jskit-ai/resource-crud-core": "0.1.40",
20
- "@jskit-ai/resource-core": "0.1.40",
21
- "@jskit-ai/uploads-runtime": "0.1.73",
15
+ "@jskit-ai/auth-core": "0.1.96",
16
+ "@jskit-ai/database-runtime": "0.1.97",
17
+ "@jskit-ai/http-runtime": "0.1.96",
18
+ "@jskit-ai/json-rest-api-core": "0.1.42",
19
+ "@jskit-ai/kernel": "0.1.98",
20
+ "@jskit-ai/resource-crud-core": "0.1.42",
21
+ "@jskit-ai/resource-core": "0.1.42",
22
+ "@jskit-ai/uploads-runtime": "0.1.75",
22
23
  "json-rest-schema": "1.x.x"
23
24
  }
24
25
  }
@@ -0,0 +1,207 @@
1
+ import { DEFAULT_USER_SETTINGS } from "../shared/settings.js";
2
+
3
+ const DEFAULT_AUTH_PROVIDER = "jskit-preview";
4
+ const DEFAULT_EMAIL = "preview@jskit.local";
5
+ const DEFAULT_DISPLAY_NAME = "JSKIT Preview";
6
+ const USERNAME_MAX_LENGTH = 120;
7
+
8
+ function normalizeText(value = "") {
9
+ return String(value || "").trim();
10
+ }
11
+
12
+ function normalizeLowerText(value = "") {
13
+ return normalizeText(value).toLowerCase();
14
+ }
15
+
16
+ function normalizeUsername(value = "") {
17
+ const normalized = normalizeLowerText(value)
18
+ .replace(/[^a-z0-9]+/g, "-")
19
+ .replace(/^-+|-+$/g, "")
20
+ .slice(0, USERNAME_MAX_LENGTH);
21
+ return normalized || "";
22
+ }
23
+
24
+ function usernameBaseFromEmail(email = "") {
25
+ const normalizedEmail = normalizeLowerText(email);
26
+ const localPart = normalizedEmail.includes("@") ? normalizedEmail.split("@")[0] : normalizedEmail;
27
+ return normalizeUsername(localPart) || "user";
28
+ }
29
+
30
+ function buildUsernameCandidate(baseUsername = "", suffix = 0) {
31
+ const base = normalizeUsername(baseUsername) || "user";
32
+ if (suffix < 1) {
33
+ return base;
34
+ }
35
+ const suffixText = `-${suffix + 1}`;
36
+ return `${base.slice(0, USERNAME_MAX_LENGTH - suffixText.length)}${suffixText}`;
37
+ }
38
+
39
+ function isDuplicateError(error) {
40
+ return (
41
+ ["23505", "ER_DUP_ENTRY", "SQLITE_CONSTRAINT", "SQLITE_CONSTRAINT_UNIQUE"].includes(String(error?.code || "")) ||
42
+ Number(error?.errno) === 1062
43
+ );
44
+ }
45
+
46
+ function normalizePreviewUserProfile(profile = {}) {
47
+ const email = normalizeLowerText(profile.email || DEFAULT_EMAIL);
48
+ const authProvider = normalizeLowerText(profile.authProvider || DEFAULT_AUTH_PROVIDER);
49
+ const username = normalizeUsername(profile.username || usernameBaseFromEmail(email));
50
+
51
+ return {
52
+ authProvider,
53
+ authProviderUserSid: normalizeText(profile.authProviderUserSid || `${authProvider}:${email}`),
54
+ displayName: normalizeText(profile.displayName || username || email || DEFAULT_DISPLAY_NAME),
55
+ email,
56
+ username: username || usernameBaseFromEmail(email)
57
+ };
58
+ }
59
+
60
+ function profileFromUserRow(user = {}, fallback = {}) {
61
+ return {
62
+ id: normalizeText(user.id),
63
+ authProvider: normalizeLowerText(user.auth_provider || fallback.authProvider || DEFAULT_AUTH_PROVIDER),
64
+ authProviderUserSid: normalizeText(user.auth_provider_user_sid || fallback.authProviderUserSid || user.id || ""),
65
+ displayName: normalizeText(user.display_name || fallback.displayName || fallback.email || DEFAULT_DISPLAY_NAME),
66
+ email: normalizeLowerText(user.email || fallback.email || DEFAULT_EMAIL),
67
+ username: normalizeUsername(user.username || fallback.username || usernameBaseFromEmail(fallback.email))
68
+ };
69
+ }
70
+
71
+ async function resolveUniqueUsername(db, baseUsername = "", { excludeUserId = "" } = {}) {
72
+ const normalizedExcludeUserId = normalizeText(excludeUserId);
73
+ for (let suffix = 0; suffix < 1000; suffix += 1) {
74
+ const candidate = buildUsernameCandidate(baseUsername, suffix);
75
+ const existing = await db("users")
76
+ .where({ username: candidate })
77
+ .first();
78
+ if (!existing || normalizeText(existing.id) === normalizedExcludeUserId) {
79
+ return candidate;
80
+ }
81
+ }
82
+ throw new Error("Unable to generate unique preview username.");
83
+ }
84
+
85
+ async function findPreviewUser(db, profile = {}) {
86
+ if (profile.email) {
87
+ const byEmail = await db("users")
88
+ .where({ email: profile.email })
89
+ .first();
90
+ if (byEmail) {
91
+ return byEmail;
92
+ }
93
+ }
94
+ return db("users")
95
+ .where({
96
+ auth_provider: profile.authProvider,
97
+ auth_provider_user_sid: profile.authProviderUserSid
98
+ })
99
+ .first();
100
+ }
101
+
102
+ async function ensurePreviewUserRow(db, profile = {}) {
103
+ const existing = await findPreviewUser(db, profile);
104
+ const username = await resolveUniqueUsername(db, profile.username, {
105
+ excludeUserId: existing?.id
106
+ });
107
+ const record = {
108
+ auth_provider: profile.authProvider,
109
+ auth_provider_user_sid: profile.authProviderUserSid,
110
+ display_name: profile.displayName,
111
+ email: profile.email,
112
+ username
113
+ };
114
+
115
+ if (existing) {
116
+ await db("users")
117
+ .where({ id: existing.id })
118
+ .update(record);
119
+ return db("users")
120
+ .where({ id: existing.id })
121
+ .first();
122
+ }
123
+
124
+ try {
125
+ await db("users").insert(record);
126
+ } catch (error) {
127
+ if (!isDuplicateError(error)) {
128
+ throw error;
129
+ }
130
+ }
131
+
132
+ const inserted = await findPreviewUser(db, {
133
+ ...profile,
134
+ username
135
+ });
136
+ if (!inserted) {
137
+ throw new Error("Preview auth profile could not be inserted or found.");
138
+ }
139
+ return inserted;
140
+ }
141
+
142
+ async function ensureUserSettings(db, user = {}) {
143
+ if (!(await db.schema.hasTable("user_settings"))) {
144
+ return false;
145
+ }
146
+ const userId = user?.id;
147
+ const existing = await db("user_settings")
148
+ .where({ user_id: userId })
149
+ .first();
150
+ if (existing) {
151
+ return false;
152
+ }
153
+ try {
154
+ await db("user_settings").insert({
155
+ user_id: userId,
156
+ theme: DEFAULT_USER_SETTINGS.theme,
157
+ locale: DEFAULT_USER_SETTINGS.locale,
158
+ time_zone: DEFAULT_USER_SETTINGS.timeZone,
159
+ date_format: DEFAULT_USER_SETTINGS.dateFormat,
160
+ number_format: DEFAULT_USER_SETTINGS.numberFormat,
161
+ currency_code: DEFAULT_USER_SETTINGS.currencyCode,
162
+ avatar_size: DEFAULT_USER_SETTINGS.avatarSize,
163
+ password_sign_in_enabled: DEFAULT_USER_SETTINGS.passwordSignInEnabled,
164
+ password_setup_required: DEFAULT_USER_SETTINGS.passwordSetupRequired,
165
+ notify_product_updates: DEFAULT_USER_SETTINGS.productUpdates,
166
+ notify_account_activity: DEFAULT_USER_SETTINGS.accountActivity,
167
+ notify_security_alerts: DEFAULT_USER_SETTINGS.securityAlerts
168
+ });
169
+ } catch (error) {
170
+ if (!isDuplicateError(error)) {
171
+ throw error;
172
+ }
173
+ }
174
+ return true;
175
+ }
176
+
177
+ async function ensurePreviewUser(db, profileInput = {}) {
178
+ if (!db || typeof db !== "function" || !db.schema || typeof db.schema.hasTable !== "function") {
179
+ throw new TypeError("ensurePreviewUser requires a Knex database instance.");
180
+ }
181
+ if (!(await db.schema.hasTable("users"))) {
182
+ return {
183
+ user: null,
184
+ profile: null,
185
+ skipped: "users table was not found"
186
+ };
187
+ }
188
+
189
+ const profile = normalizePreviewUserProfile(profileInput);
190
+ const user = await ensurePreviewUserRow(db, profile);
191
+ await ensureUserSettings(db, user);
192
+
193
+ return {
194
+ user,
195
+ profile: profileFromUserRow(user, profile),
196
+ skipped: ""
197
+ };
198
+ }
199
+
200
+ export {
201
+ DEFAULT_AUTH_PROVIDER,
202
+ DEFAULT_DISPLAY_NAME,
203
+ DEFAULT_EMAIL,
204
+ ensurePreviewUser,
205
+ normalizePreviewUserProfile,
206
+ profileFromUserRow
207
+ };
@@ -12,7 +12,10 @@ test("users-core exports are explicit and aligned with production/template usage
12
12
  const result = evaluatePackageExportsContract({
13
13
  repoRoot: REPO_ROOT,
14
14
  packageDir: PACKAGE_DIR,
15
- packageId: "@jskit-ai/users-core"
15
+ packageId: "@jskit-ai/users-core",
16
+ requiredExports: [
17
+ "./server/previewUserProvisioning"
18
+ ]
16
19
  });
17
20
 
18
21
  assert.deepEqual(
@@ -0,0 +1,125 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { ensurePreviewUser } from "../src/server/previewUserProvisioning.js";
4
+
5
+ function matches(row = {}, criteria = {}) {
6
+ return Object.entries(criteria).every(([key, value]) => row[key] === value);
7
+ }
8
+
9
+ function createMemoryDb(initialTables = {}) {
10
+ const tables = {};
11
+ for (const [tableName, rows] of Object.entries(initialTables)) {
12
+ tables[tableName] = rows.map((row) => ({ ...row }));
13
+ }
14
+
15
+ function db(tableName) {
16
+ const rows = tables[tableName];
17
+ if (!rows) {
18
+ throw new Error(`Unknown table: ${tableName}`);
19
+ }
20
+ let criteria = {};
21
+
22
+ return {
23
+ where(nextCriteria = {}) {
24
+ criteria = { ...criteria, ...nextCriteria };
25
+ return this;
26
+ },
27
+ async first() {
28
+ return rows.find((row) => matches(row, criteria)) || null;
29
+ },
30
+ async insert(record = {}) {
31
+ rows.push({
32
+ id: record.id || String(rows.length + 1),
33
+ ...record
34
+ });
35
+ },
36
+ async update(patch = {}) {
37
+ for (const row of rows.filter((entry) => matches(entry, criteria))) {
38
+ Object.assign(row, patch);
39
+ }
40
+ }
41
+ };
42
+ }
43
+
44
+ db.schema = {
45
+ async hasTable(tableName) {
46
+ return Object.hasOwn(tables, tableName);
47
+ }
48
+ };
49
+ db.tables = tables;
50
+ return db;
51
+ }
52
+
53
+ test("ensurePreviewUser creates preview user and settings with package defaults", async () => {
54
+ const db = createMemoryDb({
55
+ users: [],
56
+ user_settings: []
57
+ });
58
+
59
+ const result = await ensurePreviewUser(db, {
60
+ email: " Preview@Example.TEST ",
61
+ displayName: "Preview User"
62
+ });
63
+
64
+ assert.equal(result.skipped, "");
65
+ assert.equal(result.profile.email, "preview@example.test");
66
+ assert.equal(result.profile.username, "preview");
67
+ assert.equal(result.profile.authProvider, "jskit-preview");
68
+ assert.equal(db.tables.users.length, 1);
69
+ assert.equal(db.tables.user_settings.length, 1);
70
+ assert.equal(db.tables.user_settings[0].theme, "system");
71
+ assert.equal(db.tables.user_settings[0].password_sign_in_enabled, true);
72
+ });
73
+
74
+ test("ensurePreviewUser updates the existing preview row without stealing another username", async () => {
75
+ const db = createMemoryDb({
76
+ users: [
77
+ {
78
+ id: "1",
79
+ auth_provider: "supabase",
80
+ auth_provider_user_sid: "other",
81
+ email: "taken@example.test",
82
+ username: "preview",
83
+ display_name: "Other"
84
+ },
85
+ {
86
+ id: "2",
87
+ auth_provider: "jskit-preview",
88
+ auth_provider_user_sid: "jskit-preview:preview@example.test",
89
+ email: "preview@example.test",
90
+ username: "old-preview",
91
+ display_name: "Old"
92
+ }
93
+ ],
94
+ user_settings: [
95
+ {
96
+ user_id: "2"
97
+ }
98
+ ]
99
+ });
100
+
101
+ const result = await ensurePreviewUser(db, {
102
+ email: "preview@example.test",
103
+ username: "preview",
104
+ displayName: "New Preview"
105
+ });
106
+
107
+ assert.equal(result.profile.id, "2");
108
+ assert.equal(result.profile.username, "preview-2");
109
+ assert.equal(db.tables.users.find((row) => row.id === "2").display_name, "New Preview");
110
+ assert.equal(db.tables.user_settings.length, 1);
111
+ });
112
+
113
+ test("ensurePreviewUser skips when the users table is absent", async () => {
114
+ const db = createMemoryDb({});
115
+
116
+ const result = await ensurePreviewUser(db, {
117
+ email: "preview@example.test"
118
+ });
119
+
120
+ assert.deepEqual(result, {
121
+ user: null,
122
+ profile: null,
123
+ skipped: "users table was not found"
124
+ });
125
+ });