@rebasepro/server-mongo 0.22.0 → 0.24.0

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.
@@ -4,15 +4,13 @@ export interface MongoDoc {
4
4
  _id?: string;
5
5
  [key: string]: any;
6
6
  }
7
- import { UserRepository, RoleRepository, TokenRepository, AuthRepository, UserData, CreateUserData, RoleData, CreateRoleData, RefreshTokenInfo, RefreshTokenSession, PasswordResetTokenInfo, MagicLinkTokenInfo, UserIdentityData, ListUsersOptions, PaginatedUsersResult, MfaFactor, MfaChallengeInfo } from "@rebasepro/server";
8
- export type Role = RoleData;
7
+ import { UserRepository, TokenRepository, AuthRepository, UserData, CreateUserData, RefreshTokenInfo, RefreshTokenSession, PasswordResetTokenInfo, MagicLinkTokenInfo, UserIdentityData, ListUsersOptions, PaginatedUsersResult, MfaFactor, MfaChallengeInfo } from "@rebasepro/server";
9
8
  export declare class MongoUserService implements UserRepository {
10
9
  private db;
11
10
  constructor(db: Db);
12
11
  private get collection();
13
12
  private get identitiesCollection();
14
13
  private get userRolesCollection();
15
- private get rolesCollection();
16
14
  createUser(data: CreateUserData): Promise<UserData>;
17
15
  getUserById(id: string): Promise<UserData | null>;
18
16
  getUserByEmail(email: string): Promise<UserData | null>;
@@ -20,6 +18,14 @@ export declare class MongoUserService implements UserRepository {
20
18
  getUserIdentities(uid: string): Promise<UserIdentityData[]>;
21
19
  linkUserIdentity(uid: string, provider: string, providerId: string, profileData?: Record<string, unknown>): Promise<void>;
22
20
  updateUser(id: string, data: Partial<Omit<CreateUserData, "id">>): Promise<UserData | null>;
21
+ /**
22
+ * Everything keyed by the user goes with them. Postgres does this by
23
+ * foreign-key cascade; Mongo has none, so it is spelled out — and the
24
+ * refresh tokens are the part that matters: left behind, a deleted user's
25
+ * session kept minting access tokens for a uid that no longer exists.
26
+ * Sessions first, so a failure part-way leaves an account that cannot sign
27
+ * in rather than a session with no account.
28
+ */
23
29
  deleteUser(id: string): Promise<void>;
24
30
  listUsers(): Promise<UserData[]>;
25
31
  listUsersPaginated(options?: ListUsersOptions): Promise<PaginatedUsersResult>;
@@ -27,25 +33,14 @@ export declare class MongoUserService implements UserRepository {
27
33
  setEmailVerified(id: string, verified: boolean): Promise<void>;
28
34
  setVerificationToken(id: string, token: string | null): Promise<void>;
29
35
  getUserByVerificationToken(token: string): Promise<UserData | null>;
30
- getUserRoles(uid: string): Promise<RoleData[]>;
31
36
  getUserRoleIds(uid: string): Promise<string[]>;
32
37
  setUserRoles(uid: string, roleIds: string[]): Promise<void>;
33
38
  assignDefaultRole(uid: string, roleId: string): Promise<void>;
34
39
  getUserWithRoles(uid: string): Promise<{
35
40
  user: UserData;
36
- roles: RoleData[];
41
+ roles: string[];
37
42
  } | null>;
38
43
  }
39
- export declare class MongoRoleService implements RoleRepository {
40
- private db;
41
- constructor(db: Db);
42
- private get collection();
43
- getRoleById(id: string): Promise<RoleData | null>;
44
- listRoles(): Promise<RoleData[]>;
45
- createRole(data: CreateRoleData): Promise<RoleData>;
46
- updateRole(id: string, data: Partial<Omit<RoleData, "id">>): Promise<RoleData | null>;
47
- deleteRole(id: string): Promise<void>;
48
- }
49
44
  export declare class MongoRefreshTokenService {
50
45
  private db;
51
46
  constructor(db: Db);
@@ -105,7 +100,6 @@ export declare class MongoTokenRepository implements TokenRepository {
105
100
  export declare class MongoAuthRepository implements AuthRepository {
106
101
  private db;
107
102
  private userService;
108
- private roleService;
109
103
  private tokenRepository;
110
104
  constructor(db: Db);
111
105
  createUser(data: CreateUserData): Promise<UserData>;
@@ -122,19 +116,13 @@ export declare class MongoAuthRepository implements AuthRepository {
122
116
  setEmailVerified(id: string, verified: boolean): Promise<void>;
123
117
  setVerificationToken(id: string, token: string | null): Promise<void>;
124
118
  getUserByVerificationToken(token: string): Promise<UserData | null>;
125
- getUserRoles(uid: string): Promise<RoleData[]>;
126
119
  getUserRoleIds(uid: string): Promise<string[]>;
127
120
  setUserRoles(uid: string, roleIds: string[]): Promise<void>;
128
121
  assignDefaultRole(uid: string, roleId: string): Promise<void>;
129
122
  getUserWithRoles(uid: string): Promise<{
130
123
  user: UserData;
131
- roles: RoleData[];
124
+ roles: string[];
132
125
  } | null>;
133
- getRoleById(id: string): Promise<RoleData | null>;
134
- listRoles(): Promise<RoleData[]>;
135
- createRole(data: CreateRoleData): Promise<RoleData>;
136
- updateRole(id: string, data: Partial<Omit<RoleData, "id">>): Promise<RoleData | null>;
137
- deleteRole(id: string): Promise<void>;
138
126
  createRefreshToken(uid: string, tokenHash: string, expiresAt: Date, userAgent?: string, ipAddress?: string, session?: RefreshTokenSession): Promise<void>;
139
127
  markRefreshTokenRotated(tokenHash: string): Promise<void>;
140
128
  revokeRefreshTokenSession(sessionId: string): Promise<void>;
@@ -4,6 +4,7 @@
4
4
  * Translates Rebase filter conditions to MongoDB query operators.
5
5
  */
6
6
  import { CollectionConfig, FilterValues, LogicalCondition, OrderByTuple } from "@rebasepro/types";
7
+ import { type FieldViewer } from "@rebasepro/common";
7
8
  import { Filter, Document } from "mongodb";
8
9
  /**
9
10
  * MongoDB Condition Builder
@@ -29,16 +30,32 @@ export declare class MongoConditionBuilder {
29
30
  *
30
31
  * Always returns a condition or throws: there is no operator this can be
31
32
  * given that legitimately means "no condition".
33
+ *
34
+ * `negated` asks for the documents where the condition is *false* — not
35
+ * merely not true. The two differ by the documents where it is unknown,
36
+ * which in SQL is a comparison against NULL: `NOT (status = 'draft')` does
37
+ * not return a row whose status is NULL, and the Postgres driver is the
38
+ * reference every caller of this API writes against. So a comparison is
39
+ * false only where the field holds a value and the comparison fails on it.
40
+ * The null tests are never unknown, and negate to each other.
32
41
  */
33
42
  private static buildCondition;
34
43
  /**
35
- * Translate an `or(...)` / `and(...)` group, nesting included.
44
+ * Translate an `or(...)` / `and(...)` / `not(...)` group, nesting included.
45
+ *
46
+ * `not` negates the conjunction of its conditions — the rule stated on
47
+ * `LogicalCondition`. It used to fall through to the `and` branch, so
48
+ * `not(status = 'draft')` returned exactly the drafts. It is compiled by
49
+ * pushing the negation down to the leaves (De Morgan), each of which knows
50
+ * which documents make it false rather than merely not true — see
51
+ * {@link buildCondition}. A `$nor` around the group would be shorter and
52
+ * wrong: it also matches every document where the condition is unknown.
36
53
  *
37
54
  * Returns `undefined` for a group with nothing in it. `$or: []` is an error
38
55
  * in Mongo and `$and: []` matches every document, so neither is a
39
56
  * defensible reading of "no conditions".
40
57
  */
41
- static buildLogicalConditions(logical: LogicalCondition | undefined): Filter<Document> | undefined;
58
+ static buildLogicalConditions(logical: LogicalCondition | undefined, negated?: boolean): Filter<Document> | undefined;
42
59
  /**
43
60
  * Build search conditions for text search.
44
61
  *
@@ -50,10 +67,16 @@ export declare class MongoConditionBuilder {
50
67
  *
51
68
  * @param searchString - Text to search for
52
69
  * @param properties - The collection's properties, searched for string fields
70
+ * @param viewer - Who the search is for. A field they cannot read is not
71
+ * one they may search: the strip keeps its value out of the response, and
72
+ * without this the value is still recoverable a substring at a time by
73
+ * watching which searches return the row. Absent is the trusted server
74
+ * plane, which still does not search an `excludeFromApi` field — the same
75
+ * answer the Postgres builder gives.
53
76
  * @returns At most one MongoDB filter — callers OR what they get back, which
54
77
  * is right across fields and wrong across terms, so the AND is built here
55
78
  */
56
- static buildSearchConditions(searchString: string, properties: CollectionConfig["properties"]): Filter<Document>[];
79
+ static buildSearchConditions(searchString: string, properties: CollectionConfig["properties"], viewer?: FieldViewer): Filter<Document>[];
57
80
  /**
58
81
  * Combine multiple conditions with AND operator
59
82
  *
@@ -85,6 +108,8 @@ export declare class MongoConditionBuilder {
85
108
  logical?: LogicalCondition;
86
109
  searchString?: string;
87
110
  properties?: CollectionConfig["properties"];
111
+ /** Who the query is for — see {@link buildSearchConditions}. */
112
+ viewer?: FieldViewer;
88
113
  }): Filter<Document>;
89
114
  /**
90
115
  * The primary key's name in a stored document.
@@ -96,8 +96,39 @@ export declare class MongoDataService implements DataRepository {
96
96
  * partial row everywhere it went: the REST response, `afterSave`, the
97
97
  * history entry a revert restores from, and the row pushed to realtime
98
98
  * subscribers. Postgres returns the whole row here (`RETURNING *`).
99
+ *
100
+ * `mode` is which write the caller means when it names an `id`:
101
+ *
102
+ * - `"create"` inserts under that id, and a taken id is a 409. The driver
103
+ * authorizes a create as an insert, against the values sent — so a
104
+ * create that could land on an existing row is an update no update rule
105
+ * ever saw. It was one: an upsert with `$set`, which let a caller name
106
+ * another user's id with `status: "new"` and rewrite their row.
107
+ * - `"update"` updates that row and nothing else, and a missing one is a
108
+ * 404 — never a row conjured under an id nobody created.
109
+ * - absent, the repository's own "create or update": an upsert. Only the
110
+ * repository's direct callers reach it; the driver always says which.
111
+ *
112
+ * `values` may carry field operations (`{ views: { $inc: 1 } }`), which
113
+ * change the stored value in the same write rather than replace it — see
114
+ * {@link compileFieldOp}. They were written into the document as literal
115
+ * objects, so a counter became `{ "$inc": 1 }` behind a 200. Only an
116
+ * update can carry one: there is no stored value for an insert to act on,
117
+ * and an upsert may be an insert.
118
+ */
119
+ save<M extends Record<string, any>>(collectionPath: string, values: Partial<M>, id?: string | number, _databaseId?: string, mode?: "create" | "update"): Promise<Record<string, unknown>>;
120
+ /**
121
+ * The aggregation expression one field operation compiles to, in an update
122
+ * pipeline's `$set`.
123
+ *
124
+ * The same meaning the Postgres compiler gives each one, NULL-safety
125
+ * included: an unset or null counter increments from zero and an absent
126
+ * array is pushed onto as an empty one, rather than the write failing on
127
+ * the first use of a field. `$push` and `$pull` take a value or a list of
128
+ * values, `$pull` removes every occurrence, and `$merge` is shallow — a
129
+ * nested object replaces, it is not merged into.
99
130
  */
100
- save<M extends Record<string, any>>(collectionPath: string, values: Partial<M>, id?: string | number, _databaseId?: string): Promise<Record<string, unknown>>;
131
+ private compileFieldOp;
101
132
  /**
102
133
  * Read the document back after a write, falling back to the caller's own
103
134
  * values if it has already been removed by a concurrent delete.
@@ -115,9 +146,21 @@ export declare class MongoDataService implements DataRepository {
115
146
  */
116
147
  delete(collectionPath: string, id: string | number, _databaseId?: string): Promise<void>;
117
148
  /**
118
- * Check if a field value is unique in a collection
149
+ * Check if a field value is unique in a collection.
150
+ *
151
+ * An equality test on one field, and nothing else a query can express. The
152
+ * name and the value arrive from a socket frame, and this used to be
153
+ * `countDocuments({ [name]: value })`: a value of `{ $regex: "^123" }`
154
+ * answered, one prefix at a time, what a field of a row the caller cannot
155
+ * see begins with, and a name of `$expr` evaluated an aggregation
156
+ * expression. So an operator name is refused, the value must be a scalar,
157
+ * and it is compared with `$eq` — which reads an object as a literal even
158
+ * if one got this far.
159
+ *
160
+ * An absent value is unique, as it is on Postgres: an empty optional field
161
+ * is not "taken" by every row that also left it empty.
119
162
  */
120
- checkUniqueField(collectionPath: string, fieldName: string, value: any, excludeEntityId?: string, _databaseId?: string): Promise<boolean>;
163
+ checkUniqueField(collectionPath: string, fieldName: string, value: unknown, excludeEntityId?: string, _databaseId?: string): Promise<boolean>;
121
164
  /**
122
165
  * Generate a new row ID
123
166
  */
@@ -1,50 +1,6 @@
1
1
  import { logger } from "@rebasepro/server";
2
2
  //#region src/auth/ensure-collections.ts
3
3
  /**
4
- * Default roles to seed on first run
5
- */
6
- var DEFAULT_ROLES = [
7
- {
8
- _id: "admin",
9
- id: "admin",
10
- name: "Admin",
11
- isAdmin: true,
12
- defaultPermissions: {
13
- read: true,
14
- create: true,
15
- edit: true,
16
- delete: true
17
- },
18
- createdAt: /* @__PURE__ */ new Date()
19
- },
20
- {
21
- _id: "editor",
22
- id: "editor",
23
- name: "Editor",
24
- isAdmin: false,
25
- defaultPermissions: {
26
- read: true,
27
- create: true,
28
- edit: true,
29
- delete: true
30
- },
31
- createdAt: /* @__PURE__ */ new Date()
32
- },
33
- {
34
- _id: "viewer",
35
- id: "viewer",
36
- name: "Viewer",
37
- isAdmin: false,
38
- defaultPermissions: {
39
- read: true,
40
- create: false,
41
- edit: false,
42
- delete: false
43
- },
44
- createdAt: /* @__PURE__ */ new Date()
45
- }
46
- ];
47
- /**
48
4
  * Indexes created by releases up to 0.13.0, on snake_case field names the auth
49
5
  * services never wrote.
50
6
  *
@@ -96,7 +52,6 @@ async function ensureAuthCollectionsExist(db) {
96
52
  const resetTokens = db.collection("rebase_password_reset_tokens");
97
53
  await resetTokens.createIndex({ tokenHash: 1 }, { unique: true });
98
54
  await resetTokens.createIndex({ uid: 1 });
99
- await seedDefaultRoles(db);
100
55
  logger.info("✅ MongoDB Auth collections ready");
101
56
  } catch (error) {
102
57
  logger.error("❌ Failed to set up MongoDB auth collections", { error });
@@ -113,18 +68,7 @@ async function dropLegacyIndexes(db) {
113
68
  } catch {}
114
69
  }
115
70
  }
116
- async function seedDefaultRoles(db) {
117
- const roles = db.collection("rebase_roles");
118
- const count = await roles.countDocuments();
119
- if (count > 0) {
120
- logger.info(`📋 Found ${count} existing roles`);
121
- return;
122
- }
123
- logger.info("🌱 Seeding default roles...");
124
- for (const role of DEFAULT_ROLES) await roles.updateOne({ id: role.id }, { $setOnInsert: role }, { upsert: true });
125
- logger.info("✅ Default roles created: admin, editor, viewer");
126
- }
127
71
  //#endregion
128
72
  export { ensureAuthCollectionsExist };
129
73
 
130
- //# sourceMappingURL=ensure-collections-Bl12bSwO.js.map
74
+ //# sourceMappingURL=ensure-collections-DHTVMlxf.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ensure-collections-DHTVMlxf.js","names":[],"sources":["../src/auth/ensure-collections.ts"],"sourcesContent":["import { Db } from \"mongodb\";\nimport { logger } from \"@rebasepro/server\";\n\n/**\n * Indexes created by releases up to 0.13.0, on snake_case field names the auth\n * services never wrote.\n *\n * MongoDB indexes an absent field as `null`, so a unique index whose every key\n * component is always missing admits exactly **one** document per collection:\n * the second login on the whole deployment failed with `E11000`, as did the\n * second role assignment and the second outstanding password-reset token.\n * Dropping them is therefore part of the fix, not tidying — creating the\n * correct index alongside a broken one leaves the deployment just as broken.\n *\n * `{user_id, user_agent, ip_address}` has a second reason to go: it is the\n * reverted Postgres constraint. `MongoRefreshTokenService.createToken` explains\n * why evicting by that triple signs a second browser out; the unique index\n * expressing the same rule was left behind here.\n */\nconst LEGACY_INDEX_NAMES: Record<string, string[]> = {\n rebase_user_identities: [\"provider_1_provider_id_1\", \"user_id_1\"],\n rebase_user_roles: [\"user_id_1_role_id_1\", \"user_id_1\"],\n rebase_refresh_tokens: [\"token_hash_1\", \"user_id_1_user_agent_1_ip_address_1\", \"user_id_1\"],\n rebase_password_reset_tokens: [\"token_hash_1\", \"user_id_1\"]\n};\n\nexport async function ensureAuthCollectionsExist(db: Db): Promise<void> {\n logger.info(\"🔍 Checking MongoDB auth collections and indexes...\");\n\n try {\n await dropLegacyIndexes(db);\n\n // Every key below is a field `auth/services.ts` actually writes —\n // camelCase, matching the documents. Verified by\n // `test/MongoAuthServices.test.ts`, which now runs this function.\n\n // Users\n const users = db.collection(\"rebase_users\");\n await users.createIndex({ email: 1 }, { unique: true });\n await users.createIndex({ id: 1 });\n\n // User Identities\n const identities = db.collection(\"rebase_user_identities\");\n await identities.createIndex({ provider: 1,\nproviderId: 1 }, { unique: true });\n await identities.createIndex({ uid: 1 });\n\n // User Roles (junction collection)\n const userRoles = db.collection(\"rebase_user_roles\");\n await userRoles.createIndex({ uid: 1,\nroleId: 1 }, { unique: true });\n await userRoles.createIndex({ roleId: 1 });\n\n // Refresh Tokens. No unique index on (uid, userAgent, ipAddress):\n // tokens of one sign-in accumulate under a shared sessionId.\n const refreshTokens = db.collection(\"rebase_refresh_tokens\");\n await refreshTokens.createIndex({ tokenHash: 1 }, { unique: true });\n await refreshTokens.createIndex({ uid: 1 });\n await refreshTokens.createIndex({ sessionId: 1 });\n\n // Password Reset Tokens\n const resetTokens = db.collection(\"rebase_password_reset_tokens\");\n await resetTokens.createIndex({ tokenHash: 1 }, { unique: true });\n await resetTokens.createIndex({ uid: 1 });\n\n logger.info(\"✅ MongoDB Auth collections ready\");\n } catch (error) {\n // Not swallowed. Every index here is load-bearing for auth — a boot\n // that logs this and carries on is a deployment whose second login\n // fails, with a green boot log to say nothing is wrong.\n logger.error(\"❌ Failed to set up MongoDB auth collections\", { error: error });\n throw error;\n }\n}\n\n/** Drop the indexes of {@link LEGACY_INDEX_NAMES} that this database still has. */\nasync function dropLegacyIndexes(db: Db): Promise<void> {\n for (const [collectionName, indexNames] of Object.entries(LEGACY_INDEX_NAMES)) {\n const collection = db.collection(collectionName);\n for (const indexName of indexNames) {\n try {\n await collection.dropIndex(indexName);\n logger.info(`🧹 Dropped stale auth index ${collectionName}.${indexName}`);\n } catch {\n // NamespaceNotFound / IndexNotFound — nothing to drop here.\n }\n }\n }\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;AAmBA,IAAM,qBAA+C;CACjD,wBAAwB,CAAC,4BAA4B,WAAW;CAChE,mBAAmB,CAAC,uBAAuB,WAAW;CACtD,uBAAuB;EAAC;EAAgB;EAAuC;CAAW;CAC1F,8BAA8B,CAAC,gBAAgB,WAAW;AAC9D;AAEA,eAAsB,2BAA2B,IAAuB;CACpE,OAAO,KAAK,qDAAqD;CAEjE,IAAI;EACA,MAAM,kBAAkB,EAAE;EAO1B,MAAM,QAAQ,GAAG,WAAW,cAAc;EAC1C,MAAM,MAAM,YAAY,EAAE,OAAO,EAAE,GAAG,EAAE,QAAQ,KAAK,CAAC;EACtD,MAAM,MAAM,YAAY,EAAE,IAAI,EAAE,CAAC;EAGjC,MAAM,aAAa,GAAG,WAAW,wBAAwB;EACzD,MAAM,WAAW,YAAY;GAAE,UAAU;GACjD,YAAY;EAAE,GAAG,EAAE,QAAQ,KAAK,CAAC;EACzB,MAAM,WAAW,YAAY,EAAE,KAAK,EAAE,CAAC;EAGvC,MAAM,YAAY,GAAG,WAAW,mBAAmB;EACnD,MAAM,UAAU,YAAY;GAAE,KAAK;GAC3C,QAAQ;EAAE,GAAG,EAAE,QAAQ,KAAK,CAAC;EACrB,MAAM,UAAU,YAAY,EAAE,QAAQ,EAAE,CAAC;EAIzC,MAAM,gBAAgB,GAAG,WAAW,uBAAuB;EAC3D,MAAM,cAAc,YAAY,EAAE,WAAW,EAAE,GAAG,EAAE,QAAQ,KAAK,CAAC;EAClE,MAAM,cAAc,YAAY,EAAE,KAAK,EAAE,CAAC;EAC1C,MAAM,cAAc,YAAY,EAAE,WAAW,EAAE,CAAC;EAGhD,MAAM,cAAc,GAAG,WAAW,8BAA8B;EAChE,MAAM,YAAY,YAAY,EAAE,WAAW,EAAE,GAAG,EAAE,QAAQ,KAAK,CAAC;EAChE,MAAM,YAAY,YAAY,EAAE,KAAK,EAAE,CAAC;EAExC,OAAO,KAAK,kCAAkC;CAClD,SAAS,OAAO;EAIZ,OAAO,MAAM,+CAA+C,EAAS,MAAM,CAAC;EAC5E,MAAM;CACV;AACJ;;AAGA,eAAe,kBAAkB,IAAuB;CACpD,KAAK,MAAM,CAAC,gBAAgB,eAAe,OAAO,QAAQ,kBAAkB,GAAG;EAC3E,MAAM,aAAa,GAAG,WAAW,cAAc;EAC/C,KAAK,MAAM,aAAa,YACpB,IAAI;GACA,MAAM,WAAW,UAAU,SAAS;GACpC,OAAO,KAAK,+BAA+B,eAAe,GAAG,WAAW;EAC5E,QAAQ,CAER;CAER;AACJ"}