@rebasepro/server-postgres 0.13.1-canary.gf57a27e → 0.14.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.
Files changed (103) hide show
  1. package/dist/PostgresBootstrapper.d.ts +26 -0
  2. package/dist/auth/services.d.ts +21 -0
  3. package/dist/{auth-users-columns-Dt9g712t.js → auth-users-columns-BfQHf9JE.js} +525 -63
  4. package/dist/auth-users-columns-BfQHf9JE.js.map +1 -0
  5. package/dist/{backup-service-Bww-Lg0s.js → backup-service-BH0Dzo_h.js} +2 -3
  6. package/dist/{backup-service-Bww-Lg0s.js.map → backup-service-BH0Dzo_h.js.map} +1 -1
  7. package/dist/cli-output.d.ts +34 -0
  8. package/dist/data-transformer.d.ts +7 -2
  9. package/dist/data_driver-ULAyJEi9.js +193 -0
  10. package/dist/data_driver-ULAyJEi9.js.map +1 -0
  11. package/dist/ensure-collection-policies-8vuu-n4r.js +124 -0
  12. package/dist/ensure-collection-policies-8vuu-n4r.js.map +1 -0
  13. package/dist/{ensure-collection-tables-DRxaUG96.js → ensure-collection-tables-CbvaGuVn.js} +89 -10
  14. package/dist/ensure-collection-tables-CbvaGuVn.js.map +1 -0
  15. package/dist/index.es.js +1310 -1060
  16. package/dist/index.es.js.map +1 -1
  17. package/dist/{rls-bootstrap-sql-Bpv3nUZo.js → rls-bootstrap-sql-69hYT8nr.js} +2 -2
  18. package/dist/{rls-bootstrap-sql-Bpv3nUZo.js.map → rls-bootstrap-sql-69hYT8nr.js.map} +1 -1
  19. package/dist/rls-enforcement-BJ_3wxwg.js +425 -0
  20. package/dist/rls-enforcement-BJ_3wxwg.js.map +1 -0
  21. package/dist/schema/auth-schema.d.ts +102 -0
  22. package/dist/schema/doctor-policy-checks.d.ts +28 -0
  23. package/dist/schema/doctor.d.ts +41 -25
  24. package/dist/schema/ensure-collection-policies.d.ts +33 -9
  25. package/dist/schema/ensure-collection-tables.d.ts +60 -6
  26. package/dist/schema/generate-drizzle-schema-logic.d.ts +9 -1
  27. package/dist/schema/introspect-db-inference.d.ts +8 -1
  28. package/dist/schema/introspect-db-logic.d.ts +49 -0
  29. package/dist/schema/introspect-db-project.d.ts +21 -0
  30. package/dist/schema/search-column.d.ts +49 -0
  31. package/dist/security/policy-drift.d.ts +34 -0
  32. package/dist/security/rls-enforcement.d.ts +8 -3
  33. package/dist/services/FetchService.d.ts +9 -0
  34. package/dist/services/PersistService.d.ts +21 -17
  35. package/dist/services/RelationService.d.ts +9 -57
  36. package/dist/services/RelationWriteService.d.ts +82 -0
  37. package/dist/services/collection-helpers.d.ts +42 -0
  38. package/dist/services/dataService.d.ts +2 -0
  39. package/dist/services/junction-writes.d.ts +82 -0
  40. package/dist/services/realtimeService.d.ts +137 -2
  41. package/dist/services/write-denial.d.ts +36 -0
  42. package/dist/{src-C_wvdMnl.js → src-DCdn3Val.js} +35 -3
  43. package/dist/src-DCdn3Val.js.map +1 -0
  44. package/dist/utils/drizzle-conditions.d.ts +54 -1
  45. package/dist/{websocket-D0TBU3ia.js → websocket-C8ZqVBiV.js} +75 -18
  46. package/dist/websocket-C8ZqVBiV.js.map +1 -0
  47. package/package.json +6 -6
  48. package/src/PostgresBackendDriver.ts +7 -3
  49. package/src/PostgresBootstrapper.ts +95 -9
  50. package/src/auth/ensure-tables.ts +27 -5
  51. package/src/auth/services.ts +82 -5
  52. package/src/backup/backup-cli.ts +59 -57
  53. package/src/cli-errors.ts +6 -6
  54. package/src/cli-helpers.ts +4 -4
  55. package/src/cli-output.ts +43 -0
  56. package/src/cli.ts +155 -147
  57. package/src/collections/buildRegistry.ts +3 -1
  58. package/src/data-transformer.ts +111 -25
  59. package/src/history/ensure-history-table.ts +2 -2
  60. package/src/schema/auth-schema.ts +17 -1
  61. package/src/schema/doctor-cli.ts +14 -65
  62. package/src/schema/doctor-policy-checks.ts +105 -0
  63. package/src/schema/doctor.ts +149 -72
  64. package/src/schema/ensure-collection-policies.ts +99 -6
  65. package/src/schema/ensure-collection-tables.ts +214 -17
  66. package/src/schema/generate-drizzle-schema-logic.ts +121 -65
  67. package/src/schema/generate-drizzle-schema.ts +11 -10
  68. package/src/schema/generate-postgres-ddl-logic.ts +28 -1
  69. package/src/schema/generate-postgres-ddl.ts +14 -13
  70. package/src/schema/generated-schema-staleness.ts +7 -5
  71. package/src/schema/introspect-db-inference.ts +9 -2
  72. package/src/schema/introspect-db-logic.ts +251 -75
  73. package/src/schema/introspect-db-project.ts +78 -0
  74. package/src/schema/introspect-db.ts +42 -25
  75. package/src/schema/introspect-runtime.ts +14 -2
  76. package/src/schema/search-column.ts +85 -0
  77. package/src/security/policy-drift.test.ts +104 -3
  78. package/src/security/policy-drift.ts +129 -7
  79. package/src/security/rls-enforcement.ts +9 -4
  80. package/src/services/FetchService.ts +105 -7
  81. package/src/services/PersistService.ts +68 -42
  82. package/src/services/RelationService.ts +35 -695
  83. package/src/services/RelationWriteService.ts +653 -0
  84. package/src/services/cdc/trigger-cdc.ts +5 -1
  85. package/src/services/channel-history.ts +9 -3
  86. package/src/services/channel-presence.ts +10 -3
  87. package/src/services/collection-helpers.ts +89 -4
  88. package/src/services/dataService.ts +2 -0
  89. package/src/services/junction-writes.ts +295 -0
  90. package/src/services/pg-notify-listener.ts +1 -1
  91. package/src/services/realtimeService.ts +337 -82
  92. package/src/services/write-denial.ts +55 -0
  93. package/src/utils/drizzle-conditions.ts +211 -34
  94. package/src/utils/pg-error-utils.ts +8 -3
  95. package/src/websocket.ts +113 -16
  96. package/dist/auth-users-columns-Dt9g712t.js.map +0 -1
  97. package/dist/ensure-collection-policies-CwYUliAa.js +0 -57
  98. package/dist/ensure-collection-policies-CwYUliAa.js.map +0 -1
  99. package/dist/ensure-collection-tables-DRxaUG96.js.map +0 -1
  100. package/dist/policy-CPkCqVTz.js +0 -105
  101. package/dist/policy-CPkCqVTz.js.map +0 -1
  102. package/dist/src-C_wvdMnl.js.map +0 -1
  103. package/dist/websocket-D0TBU3ia.js.map +0 -1
@@ -34,7 +34,9 @@ export function buildCollectionRegistry(schema: RegistrySchema): PostgresCollect
34
34
 
35
35
  if (schema.collections) {
36
36
  registry.registerMultiple(schema.collections);
37
- logger.info(
37
+ // `Auto-discovered collections` already reports the count and the
38
+ // directory they came from; this is the same fact with the names.
39
+ logger.debug(
38
40
  `📋 [PostgresRegistry] Registered ${registry.getCollections().length} collections: ` +
39
41
  `[${registry.getCollections().map(c => c.slug).join(", ")}]`
40
42
  );
@@ -2,12 +2,12 @@ import { eq, SQL } from "drizzle-orm";
2
2
  import { AnyPgColumn } from "drizzle-orm/pg-core";
3
3
  import { NodePgDatabase } from "drizzle-orm/node-postgres";
4
4
  import { CollectionConfig, Properties, Property, ResolvedRelation, RelationProperty, Vector, BinaryProperty, hasForeignKeyOnTarget, type ResolvedBelongsTo, type ResolvedForeignKeyOnTarget, type ResolvedVia } from "@rebasepro/types";
5
- import { getTableName, resolveCollectionRelations, findRelation, createRelationRef, DEFAULT_ONE_OF_TYPE, DEFAULT_ONE_OF_VALUE } from "@rebasepro/common";
5
+ import { getTableName, resolveCollectionRelations, findRelation, fieldKeyForColumn, createRelationRef, DEFAULT_ONE_OF_TYPE, DEFAULT_ONE_OF_VALUE } from "@rebasepro/common";
6
6
  import { isPrototypePollutingKey } from "@rebasepro/utils";
7
7
  import { PostgresCollectionRegistry } from "./collections/PostgresCollectionRegistry";
8
8
  import { DrizzleConditionBuilder } from "./utils/drizzle-conditions";
9
9
  import { getPrimaryKeys, buildCompositeId } from "./services/collection-helpers";
10
- import { logger } from "@rebasepro/server";
10
+ import { ApiError, logger } from "@rebasepro/server";
11
11
 
12
12
  /**
13
13
  * Data transformation utilities for converting between frontend and database formats.
@@ -126,16 +126,30 @@ joinPathRelationUpdates: [] };
126
126
  newTargetId: string | number | null;
127
127
  }> = [];
128
128
 
129
- // Pre-calculate all local keys used as foreign keys
129
+ // Pre-calculate all local keys used as foreign keys.
130
+ //
131
+ // Keyed by the *wire* name: the payload arrives from a client, which knows
132
+ // the field as `authorId`, never as the `author_id` column behind it.
130
133
  const foreignKeys = new Set<string>();
131
134
  Object.values(resolvedRelations).forEach(relation => {
132
- if (relation.kind === "belongsTo") foreignKeys.add(relation.localKey);
135
+ if (relation.kind === "belongsTo") foreignKeys.add(fieldKeyForColumn(collection, relation.localKey));
133
136
  });
134
137
 
135
138
  for (const [key, value] of Object.entries(row)) {
136
139
  // Same reasoning as `sanitizeAndConvertDates`: these keys come from the
137
140
  // request body, and no column answers to them.
138
141
  if (isPrototypePollutingKey(key)) continue;
142
+
143
+ // `{ subtitle: undefined }` means "I have no value for this", not "set
144
+ // this column to NULL" — it is what spreading an optional field
145
+ // produces, and what Drizzle itself skips. The key used to survive with
146
+ // `undefined` and `sanitizeAndConvertDates` then turned it into `null`,
147
+ // so `update(id, { title, subtitle: payload.subtitle })` wiped
148
+ // `subtitle` whenever the payload happened not to carry one. Unreachable
149
+ // over HTTP (JSON has no `undefined`); the in-process data API passes
150
+ // the caller's object straight through, so it is reachable there.
151
+ if (value === undefined) continue;
152
+
139
153
  const property = properties[key as keyof M] as Property;
140
154
 
141
155
  // Coerce empty strings to null for any field that acts as a foreign key
@@ -153,15 +167,17 @@ joinPathRelationUpdates: [] };
153
167
  if (relation) {
154
168
  if (relation.kind === "belongsTo") {
155
169
  // Owning relation: Map relation object to FK column on current table
156
- const serializedValue = serializePropertyToServer(effectiveValue, property);
170
+ const serializedValue = serializePropertyToServer(effectiveValue, property, key);
157
171
  if (serializedValue !== undefined) {
158
- result[relation.localKey] = serializedValue;
172
+ // The Drizzle key, not the column: Drizzle maps it back
173
+ // to `author_id` when it builds the statement.
174
+ result[fieldKeyForColumn(collection, relation.localKey)] = serializedValue;
159
175
  }
160
176
  // Don't add the original relation property to the result
161
177
  continue;
162
178
  } else if (hasForeignKeyOnTarget(relation)) {
163
179
  // Inverse relation: Need to update the target table's FK
164
- const serializedValue = serializePropertyToServer(effectiveValue, property);
180
+ const serializedValue = serializePropertyToServer(effectiveValue, property, key);
165
181
  inverseRelationUpdates.push({
166
182
  relationKey: key,
167
183
  relation,
@@ -175,7 +191,7 @@ joinPathRelationUpdates: [] };
175
191
  // There used to be two arms here — "owning" and "inverse"
176
192
  // joinPath — but a join chain has no owning side, so they
177
193
  // only ever differed by which list they pushed to.
178
- const serializedValue = serializePropertyToServer(effectiveValue, property);
194
+ const serializedValue = serializePropertyToServer(effectiveValue, property, key);
179
195
  if (relation.cardinality === "one") {
180
196
  // Ordering matters: PersistService applies these BEFORE
181
197
  // the main UPDATE, so the mapping reads the pre-update
@@ -197,7 +213,7 @@ joinPathRelationUpdates: [] };
197
213
  }
198
214
  }
199
215
 
200
- result[key] = serializePropertyToServer(effectiveValue, property);
216
+ result[key] = serializePropertyToServer(effectiveValue, property, key);
201
217
  }
202
218
 
203
219
  return {
@@ -208,19 +224,41 @@ joinPathRelationUpdates: [] };
208
224
  }
209
225
 
210
226
  /**
211
- * Serialize a single property value for database storage
227
+ * How to name a rejected value in an error, without quoting it back.
228
+ *
229
+ * The value may be anything the caller sent, including a secret in the wrong
230
+ * field, so the message describes its shape rather than echoing it — an echoed
231
+ * value ends up in logs and in error-reporting services.
232
+ */
233
+ function describeValue(value: unknown): string {
234
+ if (value === null) return "null";
235
+ if (Array.isArray(value)) return "an array";
236
+ if (value instanceof Date) return "a date";
237
+ const type = typeof value;
238
+ return type === "object" ? "an object" : `a ${type}`;
239
+ }
240
+
241
+ /**
242
+ * Serialize a single property value for database storage.
243
+ *
244
+ * `propertyKey` is only ever used to phrase errors and warnings. Without it the
245
+ * one trace a bad value left was `Expected array value for array property, got
246
+ * string` — no collection, no property, no value, which in the log of a
247
+ * thousand-row import names nothing at all.
212
248
  */
213
- export function serializePropertyToServer(value: unknown, property: Property): unknown {
249
+ export function serializePropertyToServer(value: unknown, property: Property, propertyKey?: string): unknown {
214
250
  if (value === null || value === undefined) {
215
251
  return value;
216
252
  }
217
253
 
254
+ const fieldLabel = propertyKey ? `'${propertyKey}'` : `a '${property.type}' field`;
255
+
218
256
  const propertyType = property.type;
219
257
 
220
258
  switch (propertyType) {
221
259
  case "relation":
222
260
  if (Array.isArray(value)) {
223
- return value.map(v => serializePropertyToServer(v, property));
261
+ return value.map(v => serializePropertyToServer(v, property, propertyKey));
224
262
  } else if (typeof value === "object" && value !== null && "id" in value) {
225
263
  return (value as Record<string, unknown>).id;
226
264
  }
@@ -230,7 +268,7 @@ export function serializePropertyToServer(value: unknown, property: Property): u
230
268
  case "array":
231
269
  if (Array.isArray(value)) {
232
270
  if (property.of) {
233
- return value.map(item => serializePropertyToServer(item, property.of as Property));
271
+ return value.map(item => serializePropertyToServer(item, property.of as Property, propertyKey));
234
272
  } else if (property.oneOf) {
235
273
  const typeField = property.oneOf.typeField ?? DEFAULT_ONE_OF_TYPE;
236
274
  const valueField = property.oneOf.valueField ?? DEFAULT_ONE_OF_VALUE;
@@ -243,15 +281,50 @@ export function serializePropertyToServer(value: unknown, property: Property): u
243
281
  if (!type || !childProperty) return e;
244
282
  return {
245
283
  [typeField]: type,
246
- [valueField]: serializePropertyToServer(rec[valueField], childProperty)
284
+ [valueField]: serializePropertyToServer(rec[valueField], childProperty, propertyKey)
247
285
  };
248
286
  });
249
287
  }
250
288
  return value;
251
289
  }
252
- // Non-array value for an array propertycoerce to avoid .map() crashes downstream
253
- logger.warn(`Expected array value for array property, got ${typeof value}. Coercing to empty array.`);
254
- return [];
290
+ // A non-array value used to become `[]` here the caller's value
291
+ // destroyed, answered 200/201, and the row reading back as an empty
292
+ // list on every later fetch. `POST /posts {"tags":"news"}` from a
293
+ // client that sent a single tag as a scalar, or a CSV import that
294
+ // did not split a column, was unrecoverable data loss on a `text[]`
295
+ // column. The stated reason for coercing — "avoid .map() crashes
296
+ // downstream" — is better served by refusing the value at the
297
+ // boundary, which is what a 400 does.
298
+ //
299
+ // The read-side twin (`parsePropertyFromServer`) still coerces, and
300
+ // should: a row already in the database is not this caller's fault.
301
+ throw ApiError.badRequest(
302
+ `${fieldLabel} expects an array, but received ${describeValue(value)}.`,
303
+ "VALIDATION_INVALID_VALUE"
304
+ );
305
+
306
+ case "geopoint": {
307
+ // Stored as `jsonb`, in the `{ latitude, longitude }` shape the
308
+ // OpenAPI schema and the generated TS type both promise. Nothing
309
+ // used to handle `geopoint` on either side, which is why the type
310
+ // was documented, code-generated, admin-editable — and dropped.
311
+ if (typeof value !== "object" || Array.isArray(value)) {
312
+ throw ApiError.badRequest(
313
+ `${fieldLabel} expects a geopoint object with \`latitude\` and \`longitude\`, ` +
314
+ `but received ${describeValue(value)}.`,
315
+ "VALIDATION_INVALID_VALUE"
316
+ );
317
+ }
318
+ const point = value as Record<string, unknown>;
319
+ if (typeof point.latitude !== "number" || typeof point.longitude !== "number") {
320
+ throw ApiError.badRequest(
321
+ `${fieldLabel} expects a geopoint object with numeric \`latitude\` and \`longitude\`.`,
322
+ "VALIDATION_INVALID_VALUE"
323
+ );
324
+ }
325
+ return { latitude: point.latitude,
326
+ longitude: point.longitude };
327
+ }
255
328
 
256
329
 
257
330
  case "map":
@@ -260,7 +333,7 @@ export function serializePropertyToServer(value: unknown, property: Property): u
260
333
  for (const [subKey, subValue] of Object.entries(value)) {
261
334
  const subProperty = (property.properties as Properties)[subKey];
262
335
  if (subProperty) {
263
- result[subKey] = serializePropertyToServer(subValue, subProperty);
336
+ result[subKey] = serializePropertyToServer(subValue, subProperty, propertyKey ? `${propertyKey}.${subKey}` : subKey);
264
337
  } else {
265
338
  result[subKey] = subValue;
266
339
  }
@@ -321,9 +394,12 @@ export async function parseDataFromServer<M extends Record<string, unknown>>(
321
394
  // Find the normalized relation for this property
322
395
  const relation = findRelation(resolvedRelations, propKey);
323
396
  if (relation) {
324
- if (relation.kind === "belongsTo" && relation.localKey in data) {
325
- // Owning relation: FK is in current table
326
- const fkValue = data[relation.localKey as keyof M];
397
+ const localField = relation.kind === "belongsTo"
398
+ ? fieldKeyForColumn(collection, relation.localKey)
399
+ : "";
400
+ if (relation.kind === "belongsTo" && localField in data) {
401
+ // Owning relation: FK is in current table, under its wire name
402
+ const fkValue = data[localField as keyof M];
327
403
  if (fkValue !== null && fkValue !== undefined) {
328
404
  try {
329
405
  const targetCollection = relation.target();
@@ -347,7 +423,8 @@ export async function parseDataFromServer<M extends Record<string, unknown>>(
347
423
  : buildCompositeId(data, pks);
348
424
 
349
425
  if (targetTable && currentId !== undefined && currentId !== null && currentId !== "") {
350
- const foreignKeyColumn = targetTable[relation.foreignKeyOnTarget as keyof typeof targetTable] as AnyPgColumn;
426
+ const fkFieldKey = fieldKeyForColumn(targetCollection, relation.foreignKeyOnTarget);
427
+ const foreignKeyColumn = targetTable[fkFieldKey as keyof typeof targetTable] as AnyPgColumn;
351
428
  if (foreignKeyColumn) {
352
429
  // Query the target table to find row that references this row
353
430
  const relatedRows = await db
@@ -626,6 +703,13 @@ export function parsePropertyFromServer(value: unknown, property: Property, coll
626
703
  }
627
704
  return value;
628
705
 
706
+ case "geopoint":
707
+ // A `jsonb` column, so node-postgres hands back the object already.
708
+ // Named explicitly rather than left to `default:`, which would run
709
+ // it past the Buffer probe — and because a type with a write
710
+ // serializer and no read counterpart is how this one went missing.
711
+ return value;
712
+
629
713
  case "vector": {
630
714
  let nums: number[] = [];
631
715
  if (typeof value === "string") {
@@ -706,9 +790,11 @@ function normalizeScalarValues<M extends Record<string, unknown>>(
706
790
  // Identify FK columns used only for relations and not exposed as properties
707
791
  const internalFKColumns = new Set<string>();
708
792
  Object.values(resolvedRelations).forEach(relation => {
709
- if (relation.kind === "belongsTo" && !properties[relation.localKey]) {
710
- internalFKColumns.add(relation.localKey);
711
- }
793
+ if (relation.kind !== "belongsTo") return;
794
+ // The key a row carries this foreign key under, which is the wire name
795
+ // — `authorId`, not the `author_id` column.
796
+ const localField = fieldKeyForColumn(collection, relation.localKey);
797
+ if (!properties[localField]) internalFKColumns.add(localField);
712
798
  });
713
799
 
714
800
  for (const [key, value] of Object.entries(data)) {
@@ -9,7 +9,7 @@ import { revokeInternalTableSql } from "@rebasepro/common";
9
9
  * pattern as `ensureAuthTablesExist`.
10
10
  */
11
11
  export async function ensureHistoryTableExists(db: NodePgDatabase): Promise<void> {
12
- logger.info("🔍 Checking row history table...");
12
+ logger.debug("🔍 Checking row history table...");
13
13
 
14
14
  try {
15
15
  // Create the rebase schema (idempotent — may already exist from auth init)
@@ -45,7 +45,7 @@ export async function ensureHistoryTableExists(db: NodePgDatabase): Promise<void
45
45
  // (created here, after that grant ran), so take it back.
46
46
  await db.execute(sql.raw(revokeInternalTableSql("rebase", "entity_history")));
47
47
 
48
- logger.info("✅ Entity history table ready");
48
+ logger.debug("✅ Entity history table ready");
49
49
  } catch (error) {
50
50
  logger.error("❌ Failed to create row history table", { error: error });
51
51
  logger.warn("⚠️ Continuing without creating history table.");
@@ -1,4 +1,4 @@
1
- import { pgSchema, pgTable, uuid, timestamp, boolean, jsonb, text, unique, index } from "drizzle-orm/pg-core";
1
+ import { pgSchema, pgTable, uuid, timestamp, boolean, jsonb, text, unique, index, integer, bigint } from "drizzle-orm/pg-core";
2
2
  import { relations } from "drizzle-orm";
3
3
 
4
4
  /**
@@ -87,6 +87,13 @@ export function createAuthSchema(usersSchemaName = "rebase") {
87
87
  * that rotates immediately after it.
88
88
  */
89
89
  sessionStartedAt: timestamp("session_started_at").defaultNow().notNull(),
90
+ /**
91
+ * The assurance level the sign-in was established at — `aal2` only
92
+ * where a second factor was actually presented. Carried across
93
+ * rotations, because refresh is not a new authentication and has
94
+ * nothing else to read the level from.
95
+ */
96
+ aal: text("aal"),
90
97
  userAgent: text("user_agent"),
91
98
  ipAddress: text("ip_address"),
92
99
  createdAt: timestamp("created_at").defaultNow().notNull()
@@ -140,6 +147,13 @@ export function createAuthSchema(usersSchemaName = "rebase") {
140
147
  secretEncrypted: text("secret_encrypted").notNull(),
141
148
  friendlyName: text("friendly_name"),
142
149
  verified: boolean("verified").default(false).notNull(),
150
+ /**
151
+ * The highest TOTP time step ever accepted for this factor. RFC 6238
152
+ * §5.2 forbids accepting an OTP twice, and the ±1 step window that
153
+ * exists for clock drift is also a 90-second replay window: without
154
+ * this, one observed code buys a fresh session for a minute and a half.
155
+ */
156
+ lastUsedCounter: bigint("last_used_counter", { mode: "number" }),
143
157
  createdAt: timestamp("created_at").defaultNow().notNull(),
144
158
  updatedAt: timestamp("updated_at").defaultNow().notNull()
145
159
  });
@@ -153,6 +167,8 @@ export function createAuthSchema(usersSchemaName = "rebase") {
153
167
  createdAt: timestamp("created_at").defaultNow().notNull(),
154
168
  verifiedAt: timestamp("verified_at"),
155
169
  ipAddress: text("ip_address"),
170
+ /** Failed guesses recorded against this challenge; bounded by the route. */
171
+ attempts: integer("attempts").default(0).notNull(),
156
172
  expiresAt: timestamp("expires_at").notNull()
157
173
  });
158
174
 
@@ -5,62 +5,8 @@
5
5
  */
6
6
  import path from "path";
7
7
  import chalk from "chalk";
8
- import fs from "fs";
9
- import { runDoctor, loadCollections } from "./doctor";
10
- import { checkPolicyDrift, formatPolicyDrift, hasDrift } from "../security/policy-drift";
11
- import { validatePolicyPgRoles, warnOnAnonymousGrants } from "../security/rls-enforcement";
12
- import { logger } from "@rebasepro/server";
13
-
14
- /**
15
- * The RLS half of the doctor: policies actually deployed vs the ones the
16
- * collections describe, plus policy roles this server could never satisfy.
17
- *
18
- * Returns true when something is wrong, so callers can set the exit code.
19
- */
20
- async function runPolicyChecks(collectionsPath: string, databaseUrl?: string): Promise<boolean> {
21
- if (!databaseUrl) {
22
- logger.warn(chalk.yellow(" ⚠ No DATABASE_URL — skipping RLS policy checks"));
23
- return false;
24
- }
25
-
26
- let problems = false;
27
- const { Pool } = await import("pg");
28
- const pool = new Pool({ connectionString: databaseUrl });
29
- try {
30
- const collections = await loadCollections(path.resolve(process.cwd(), collectionsPath));
31
- const runSql = async (text: string) => (await pool.query(text)).rows as Record<string, unknown>[];
32
-
33
- // A policy naming a role the server never runs as filters every row, so
34
- // the collection reads as empty. Report it without booting a server.
35
- try {
36
- await validatePolicyPgRoles(runSql, collections as never);
37
- logger.info(chalk.green(" ✓ Policy roles are usable by this server"));
38
- } catch (err) {
39
- problems = true;
40
- logger.info("");
41
- logger.error(chalk.red(err instanceof Error ? err.message : String(err)));
42
- }
43
-
44
- // A rule that reads as a lockdown but is true for every caller compiles
45
- // to a grant. Report it without booting a server.
46
- warnOnAnonymousGrants(collections as never);
47
-
48
- const drift = await checkPolicyDrift(pool as never, collections);
49
- logger.info("");
50
- if (hasDrift(drift)) {
51
- problems = true;
52
- logger.info(chalk.yellow(" RLS policies: database does not match your collections"));
53
- logger.info(formatPolicyDrift(drift));
54
- } else {
55
- logger.info(chalk.green(" ✓ RLS policies match your collections"));
56
- }
57
- } catch (err) {
58
- logger.warn(chalk.yellow(" ⚠ Could not check RLS policies"), { error: err });
59
- } finally {
60
- await pool.end();
61
- }
62
- return problems;
63
- }
8
+ import { runDoctor } from "./doctor";
9
+ import { exitCodeForPolicyGate, runPolicyChecks } from "./doctor-policy-checks";
64
10
 
65
11
  async function main() {
66
12
  const collectionsArg = process.argv.find((a) => a.startsWith("--collections="));
@@ -80,9 +26,9 @@ async function main() {
80
26
  const dotenv = await import("dotenv");
81
27
  const envPath = process.env.DOTENV_CONFIG_PATH;
82
28
  if (envPath) {
83
- dotenv.config({ path: envPath });
29
+ dotenv.config({ path: envPath, quiet: true });
84
30
  } else {
85
- dotenv.config();
31
+ dotenv.config({ quiet: true });
86
32
  }
87
33
  } catch {
88
34
  // dotenv may not be installed
@@ -91,9 +37,9 @@ async function main() {
91
37
  const databaseUrl = process.env.DATABASE_URL || process.env.ADMIN_CONNECTION_STRING;
92
38
 
93
39
  if (policiesOnly) {
94
- // Non-zero so this can gate CI — the whole point of the flag.
95
- if (await runPolicyChecks(collectionsPath, databaseUrl)) process.exit(1);
96
- return;
40
+ // Non-zero so this can gate CI — the whole point of the flag, and a
41
+ // check that could not run has not passed. See exitCodeForPolicyGate.
42
+ process.exit(exitCodeForPolicyGate(await runPolicyChecks(collectionsPath, databaseUrl)));
97
43
  }
98
44
 
99
45
  const report = await runDoctor({
@@ -103,15 +49,18 @@ async function main() {
103
49
  databaseUrl: databaseUrl ?? undefined
104
50
  });
105
51
 
106
- const policiesDrifted = await runPolicyChecks(collectionsPath, databaseUrl);
52
+ const policyStatus = await runPolicyChecks(collectionsPath, databaseUrl);
107
53
 
108
- // Exit with non-zero code if there are errors
109
- if (report.summary.errors > 0 || policiesDrifted) {
54
+ // Exit non-zero if there are errors. A policy run that could not happen is
55
+ // reported loudly above but does not fail the interactive command — the
56
+ // same treatment the skipped database phase gets. `--policies` is the gate,
57
+ // and that one fails closed.
58
+ if (report.summary.errors > 0 || policyStatus === "problems") {
110
59
  process.exit(1);
111
60
  }
112
61
  }
113
62
 
114
63
  main().catch((err) => {
115
- logger.error(chalk.red(" ✗ Doctor failed"), { error: err });
64
+ console.error(chalk.red(" ✗ Doctor failed"), err instanceof Error ? (err.stack ?? err.message) : String(err));
116
65
  process.exit(1);
117
66
  });
@@ -0,0 +1,105 @@
1
+ /**
2
+ * The RLS half of `rebase doctor`, and the exit code `--policies` gates CI on.
3
+ *
4
+ * Lives beside `doctor-cli.ts` rather than inside it because that file runs
5
+ * `main()` on import: a gate whose failure modes cannot be unit-tested is how
6
+ * this one shipped reporting success for work it never did.
7
+ */
8
+ import path from "path";
9
+ import chalk from "chalk";
10
+ import { CollectionConfig } from "@rebasepro/types";
11
+ import { loadCollections } from "./doctor";
12
+ import { checkPolicyDrift, formatPolicyDrift, hasDrift } from "../security/policy-drift";
13
+ import { validatePolicyPgRoles, warnOnAnonymousGrants } from "../security/rls-enforcement";
14
+
15
+ /**
16
+ * What the RLS checks concluded.
17
+ *
18
+ * `unchecked` exists because "we could not look" and "we looked and it is fine"
19
+ * used to be reported identically: a collections path that did not resolve made
20
+ * the loader return `[]` (it warns, it does not throw), `checkPolicyDrift`
21
+ * early-returned an empty diff, and the gate printed
22
+ * `✓ RLS policies match your collections` having compared zero policies against
23
+ * zero collections. Any exception at all — a collection file that throws on
24
+ * import, a `pg_policies` read the CI role is not granted, a connection reset —
25
+ * did the same thing through a `warn`, and exited 0.
26
+ */
27
+ export type PolicyCheckStatus = "ok" | "problems" | "unchecked";
28
+
29
+ /**
30
+ * The exit code for `rebase doctor --policies`.
31
+ *
32
+ * A gate that could not run has not passed. Only a completed, clean check
33
+ * exits 0 — anything else, including "we never opened a connection", is a
34
+ * failure, or the flag certifies a database nobody looked at.
35
+ */
36
+ export function exitCodeForPolicyGate(status: PolicyCheckStatus): 0 | 1 {
37
+ return status === "ok" ? 0 : 1;
38
+ }
39
+
40
+ /**
41
+ * Policies actually deployed vs the ones the collections describe, plus policy
42
+ * roles this server could never satisfy.
43
+ *
44
+ * Never reports `ok` for work it did not do — see {@link PolicyCheckStatus}.
45
+ */
46
+ export async function runPolicyChecks(collectionsPath: string, databaseUrl?: string): Promise<PolicyCheckStatus> {
47
+ if (!databaseUrl) {
48
+ console.error(chalk.yellow(" ⚠ No DATABASE_URL — RLS policies were NOT checked"));
49
+ return "unchecked";
50
+ }
51
+
52
+ const resolvedPath = path.resolve(process.cwd(), collectionsPath);
53
+ let problems = false;
54
+ const { Pool } = await import("pg");
55
+ const pool = new Pool({ connectionString: databaseUrl });
56
+ try {
57
+ const collections: CollectionConfig[] = await loadCollections(resolvedPath);
58
+
59
+ // Zero collections is not "no drift", it is nothing to compare. The
60
+ // loader returns `[]` for a path that does not exist, so the commonest
61
+ // way to get here is a `--collections` path resolved against the wrong
62
+ // directory — which used to render as a green tick.
63
+ if (collections.length === 0) {
64
+ console.error(chalk.red(` ✗ No collections found in ${resolvedPath}`));
65
+ console.error(chalk.gray(" RLS policies were NOT checked. Pass --collections=<dir> if your collections live elsewhere."));
66
+ return "unchecked";
67
+ }
68
+
69
+ const runSql = async (text: string) => (await pool.query(text)).rows as Record<string, unknown>[];
70
+
71
+ // A policy naming a role the server never runs as filters every row, so
72
+ // the collection reads as empty. Report it without booting a server.
73
+ try {
74
+ await validatePolicyPgRoles(runSql, collections as never);
75
+ console.log(chalk.green(" ✓ Policy roles are usable by this server"));
76
+ } catch (err) {
77
+ problems = true;
78
+ console.log("");
79
+ console.error(chalk.red(err instanceof Error ? err.message : String(err)));
80
+ }
81
+
82
+ // A rule that reads as a lockdown but is true for every caller compiles
83
+ // to a grant. Report it without booting a server.
84
+ warnOnAnonymousGrants(collections as never);
85
+
86
+ const drift = await checkPolicyDrift(pool as never, collections);
87
+ console.log("");
88
+ if (hasDrift(drift)) {
89
+ problems = true;
90
+ console.log(chalk.yellow(" RLS policies: database does not match your collections"));
91
+ console.log(formatPolicyDrift(drift));
92
+ } else {
93
+ console.log(chalk.green(` ✓ RLS policies match your collections (${collections.length} collection(s) checked)`));
94
+ }
95
+ } catch (err) {
96
+ // Fail closed. This catch covers every query and every collection import
97
+ // above, so returning the pre-catch verdict made the documented CI gate
98
+ // green on exactly the runs it exists to catch.
99
+ console.error(chalk.red(" ✗ Could not check RLS policies:"), err instanceof Error ? err.message : String(err));
100
+ return "unchecked";
101
+ } finally {
102
+ await pool.end();
103
+ }
104
+ return problems ? "problems" : "ok";
105
+ }