@rebasepro/server-postgres 0.10.1-canary.31c773c → 0.10.1-canary.6f89f77
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/auth/services.d.ts +43 -4
- package/dist/backup/backup-logic.d.ts +23 -0
- package/dist/backup/backup-service.d.ts +44 -2
- package/dist/backup/pg-tools.d.ts +41 -1
- package/dist/cli-helpers.d.ts +33 -1
- package/dist/index.es.js +439 -85
- package/dist/index.es.js.map +1 -1
- package/dist/schema/auth-schema.d.ts +170 -0
- package/dist/schema/destructive-sql.d.ts +49 -0
- package/package.json +6 -6
- package/src/auth/ensure-tables.ts +70 -56
- package/src/auth/services.ts +186 -48
- package/src/backup/backup-cli.ts +60 -1
- package/src/backup/backup-cron.ts +24 -1
- package/src/backup/backup-logic.ts +62 -0
- package/src/backup/backup-service.ts +132 -13
- package/src/backup/pg-tools.ts +70 -2
- package/src/cli-helpers.ts +82 -27
- package/src/cli.ts +152 -6
- package/src/schema/auth-schema.ts +41 -3
- package/src/schema/destructive-sql.ts +94 -0
- package/src/services/realtimeService.ts +13 -3
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { pgSchema, pgTable, varchar, uuid, timestamp, boolean, jsonb, text, unique } from "drizzle-orm/pg-core";
|
|
1
|
+
import { pgSchema, pgTable, varchar, uuid, timestamp, boolean, jsonb, text, unique, index } from "drizzle-orm/pg-core";
|
|
2
2
|
import { relations } from "drizzle-orm";
|
|
3
3
|
|
|
4
4
|
/**
|
|
@@ -25,24 +25,62 @@ export function createAuthSchema(usersSchemaName = "rebase") {
|
|
|
25
25
|
isAnonymous: boolean("is_anonymous").default(false).notNull(),
|
|
26
26
|
roles: text("roles").array().default([]).notNull(),
|
|
27
27
|
metadata: jsonb("metadata").$type<Record<string, unknown>>().default({}).notNull(),
|
|
28
|
+
/**
|
|
29
|
+
* Sessions that began before this instant are dead, whatever tokens
|
|
30
|
+
* they still hold. Password resets and admin revocations stamp it.
|
|
31
|
+
*
|
|
32
|
+
* Deleting the user's refresh-token rows (which we also do) is not
|
|
33
|
+
* sufficient on its own: a request already in flight can insert a
|
|
34
|
+
* freshly rotated row microseconds after the delete and survive it.
|
|
35
|
+
* This timestamp cannot be outrun that way — it is checked against
|
|
36
|
+
* `refresh_tokens.session_started_at`, which rotation carries forward.
|
|
37
|
+
*/
|
|
38
|
+
tokensValidAfter: timestamp("tokens_valid_after"),
|
|
28
39
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
|
29
40
|
updatedAt: timestamp("updated_at").defaultNow().notNull()
|
|
30
41
|
});
|
|
31
42
|
|
|
32
43
|
|
|
33
44
|
/**
|
|
34
|
-
* Refresh tokens for long-lived sessions
|
|
45
|
+
* Refresh tokens for long-lived sessions.
|
|
46
|
+
*
|
|
47
|
+
* A row is one token, not one device. Every token minted from the same
|
|
48
|
+
* sign-in shares a `sessionId`, and rotation ADDS a row rather than
|
|
49
|
+
* replacing one: the superseded token stays on file, flagged `revoked`
|
|
50
|
+
* with a `rotatedAt` stamp. That record is what lets the refresh endpoint
|
|
51
|
+
* tell a client replaying a token it never got an answer for (a response
|
|
52
|
+
* lost to a redeploy, a second tab racing on boot) apart from a stranger
|
|
53
|
+
* presenting a token that was never issued. Deleting the old row on sight
|
|
54
|
+
* — the previous behaviour — made those two cases indistinguishable, and
|
|
55
|
+
* the legitimate one is overwhelmingly the common one.
|
|
56
|
+
*
|
|
57
|
+
* There is deliberately NO unique constraint on (uid, user_agent,
|
|
58
|
+
* ip_address). Keying a session on the IP meant one row per "device",
|
|
59
|
+
* so a second browser profile behind the same NAT silently evicted the
|
|
60
|
+
* first, and a phone changing networks orphaned a row on every hop.
|
|
61
|
+
* User agent and IP are descriptive metadata for the sessions list;
|
|
62
|
+
* `sessionId` is the identity.
|
|
35
63
|
*/
|
|
36
64
|
const refreshTokens = tableCreator("refresh_tokens", {
|
|
37
65
|
id: uuid("id").defaultRandom().primaryKey(),
|
|
38
66
|
uid: uuid("uid").notNull().references(() => users.id, { onDelete: "cascade" }),
|
|
67
|
+
sessionId: uuid("session_id").defaultRandom().notNull(),
|
|
39
68
|
tokenHash: varchar("token_hash", { length: 255 }).notNull().unique(),
|
|
40
69
|
expiresAt: timestamp("expires_at").notNull(),
|
|
70
|
+
revoked: boolean("revoked").default(false).notNull(),
|
|
71
|
+
rotatedAt: timestamp("rotated_at"),
|
|
72
|
+
/**
|
|
73
|
+
* When the sign-in this token descends from happened — carried across
|
|
74
|
+
* every rotation, unlike `createdAt`. `users.tokensValidAfter` is
|
|
75
|
+
* compared against this, so a revocation cannot be outrun by a token
|
|
76
|
+
* that rotates immediately after it.
|
|
77
|
+
*/
|
|
78
|
+
sessionStartedAt: timestamp("session_started_at").defaultNow().notNull(),
|
|
41
79
|
userAgent: varchar("user_agent", { length: 500 }),
|
|
42
80
|
ipAddress: varchar("ip_address", { length: 45 }),
|
|
43
81
|
createdAt: timestamp("created_at").defaultNow().notNull()
|
|
44
82
|
}, (table) => ({
|
|
45
|
-
|
|
83
|
+
sessionIdx: index("idx_refresh_tokens_session").on(table.sessionId)
|
|
46
84
|
}));
|
|
47
85
|
|
|
48
86
|
/**
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure helpers that classify an Atlas declarative-apply plan as destructive
|
|
3
|
+
* or not, and decide what `rebase db push` should do about it.
|
|
4
|
+
*
|
|
5
|
+
* `db push` runs `atlas schema apply` to make the live database match the
|
|
6
|
+
* generated `schema.sql`. Removing a collection field compiles to
|
|
7
|
+
* `DROP COLUMN`; renaming compiles to drop-then-add — either destroys data.
|
|
8
|
+
* We first run the apply with `--dry-run` to obtain the planned SQL, scan it
|
|
9
|
+
* here, and refuse to auto-approve anything destructive without an explicit
|
|
10
|
+
* opt-in.
|
|
11
|
+
*
|
|
12
|
+
* Everything in this file is side-effect free so it can be unit-tested
|
|
13
|
+
* without Atlas or a database.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* SQL fragments that destroy data or data-bearing objects. Matched
|
|
18
|
+
* case-insensitively against each statement of the plan. `IF EXISTS` /
|
|
19
|
+
* whitespace variations are tolerated by the regexes below.
|
|
20
|
+
*/
|
|
21
|
+
const DESTRUCTIVE_PATTERNS: { label: string; re: RegExp }[] = [
|
|
22
|
+
{ label: "DROP TABLE", re: /\bDROP\s+TABLE\b/i },
|
|
23
|
+
{ label: "DROP COLUMN", re: /\bDROP\s+COLUMN\b/i },
|
|
24
|
+
{ label: "DROP SCHEMA", re: /\bDROP\s+SCHEMA\b/i },
|
|
25
|
+
{ label: "DROP VIEW", re: /\bDROP\s+(MATERIALIZED\s+)?VIEW\b/i },
|
|
26
|
+
{ label: "DROP TYPE", re: /\bDROP\s+TYPE\b/i },
|
|
27
|
+
{ label: "TRUNCATE", re: /\bTRUNCATE\b/i }
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Split a SQL script into individual statements, dropping blank lines and
|
|
32
|
+
* `--` comment lines. Deliberately simple: Atlas emits one plain statement
|
|
33
|
+
* per `;`, without string literals that contain semicolons in a schema DDL
|
|
34
|
+
* plan, so a naive split is safe and keeps this dependency-free.
|
|
35
|
+
*/
|
|
36
|
+
export function splitSqlStatements(sql: string): string[] {
|
|
37
|
+
// Strip full-line SQL comments so a commented-out DROP never trips the
|
|
38
|
+
// detector, then split on semicolons.
|
|
39
|
+
const withoutComments = sql
|
|
40
|
+
.split("\n")
|
|
41
|
+
.filter((line) => !line.trim().startsWith("--"))
|
|
42
|
+
.join("\n");
|
|
43
|
+
return withoutComments
|
|
44
|
+
.split(";")
|
|
45
|
+
.map((s) => s.trim())
|
|
46
|
+
.filter((s) => s.length > 0);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface DestructiveStatement {
|
|
50
|
+
/** The offending statement (trimmed, without the trailing `;`). */
|
|
51
|
+
statement: string;
|
|
52
|
+
/** Which destructive operation it was flagged for, e.g. "DROP COLUMN". */
|
|
53
|
+
kind: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Scan an Atlas plan (the SQL printed by `schema apply --dry-run`) and return
|
|
58
|
+
* the statements that would destroy data. An empty array means the plan is
|
|
59
|
+
* safe to auto-approve.
|
|
60
|
+
*/
|
|
61
|
+
export function detectDestructiveStatements(planSql: string): DestructiveStatement[] {
|
|
62
|
+
const found: DestructiveStatement[] = [];
|
|
63
|
+
for (const statement of splitSqlStatements(planSql)) {
|
|
64
|
+
for (const { label, re } of DESTRUCTIVE_PATTERNS) {
|
|
65
|
+
if (re.test(statement)) {
|
|
66
|
+
found.push({ statement, kind: label });
|
|
67
|
+
break; // one label per statement is enough to flag it
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return found;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export type PushDecision = "apply" | "confirm" | "refuse";
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Decide how `db push` should proceed given the plan's destructiveness and
|
|
78
|
+
* the invocation context.
|
|
79
|
+
*
|
|
80
|
+
* - No destructive statements → `apply` (safe to auto-approve).
|
|
81
|
+
* - Destructive + `--allow-destructive` → `apply` (operator opted in).
|
|
82
|
+
* - Destructive + interactive TTY → `confirm` (prompt before applying).
|
|
83
|
+
* - Destructive + non-interactive → `refuse` (never silently drop data in
|
|
84
|
+
* CI / scripts / agents).
|
|
85
|
+
*/
|
|
86
|
+
export function decidePushSafety(opts: {
|
|
87
|
+
destructiveCount: number;
|
|
88
|
+
allowDestructive: boolean;
|
|
89
|
+
interactive: boolean;
|
|
90
|
+
}): PushDecision {
|
|
91
|
+
if (opts.destructiveCount === 0) return "apply";
|
|
92
|
+
if (opts.allowDestructive) return "apply";
|
|
93
|
+
return opts.interactive ? "confirm" : "refuse";
|
|
94
|
+
}
|
|
@@ -4,7 +4,7 @@ import { Client as PgClient } from "pg";
|
|
|
4
4
|
import { randomUUID } from "crypto";
|
|
5
5
|
import { DataService } from "./dataService";
|
|
6
6
|
|
|
7
|
-
import { FetchCollectionProps, ListenCollectionProps, ListenOneProps, DataDriver, CollectionUpdateMessage, SingleUpdateMessage, CollectionPatchMessage, WebSocketMessage, FilterValues, CollectionConfig, RebaseCallContext } from "@rebasepro/types";
|
|
7
|
+
import { FetchCollectionProps, ListenCollectionProps, ListenOneProps, DataDriver, CollectionUpdateMessage, SingleUpdateMessage, CollectionPatchMessage, WebSocketMessage, FilterValues, CollectionConfig, RebaseCallContext, resolveClientListLimit } from "@rebasepro/types";
|
|
8
8
|
import { NodePgDatabase } from "drizzle-orm/node-postgres";
|
|
9
9
|
import { sql as drizzleSql } from "drizzle-orm";
|
|
10
10
|
import { RealtimeProvider, CollectionSubscriptionConfig, SingleSubscriptionConfig } from "../interfaces";
|
|
@@ -391,6 +391,16 @@ export class RealtimeService extends EventEmitter implements RealtimeProvider {
|
|
|
391
391
|
return;
|
|
392
392
|
}
|
|
393
393
|
|
|
394
|
+
// Bound the client-supplied limit with the SAME guarantee the REST
|
|
395
|
+
// ingress applies (`resolveClientListLimit`): clamp to the hard max
|
|
396
|
+
// and default an absent limit by mode. A subscription is re-fetched
|
|
397
|
+
// on every matching write, so an unbounded one is a DoS amplified
|
|
398
|
+
// per write — resolve it once and reuse for the stored request and
|
|
399
|
+
// the initial fetch.
|
|
400
|
+
const boundedLimit = resolveClientListLimit(request.limit, {
|
|
401
|
+
vectorSearch: !!request.vectorSearch
|
|
402
|
+
});
|
|
403
|
+
|
|
394
404
|
// Store subscription with full request parameters and auth context for RLS
|
|
395
405
|
this._subscriptions.set(subscriptionId, {
|
|
396
406
|
clientId,
|
|
@@ -400,7 +410,7 @@ export class RealtimeService extends EventEmitter implements RealtimeProvider {
|
|
|
400
410
|
filter: request.filter,
|
|
401
411
|
orderBy: request.orderBy,
|
|
402
412
|
order: request.order,
|
|
403
|
-
limit:
|
|
413
|
+
limit: boundedLimit,
|
|
404
414
|
startAfter: request.startAfter as Record<string, unknown> | undefined,
|
|
405
415
|
databaseId: request.collection?.databaseId,
|
|
406
416
|
searchString: request.searchString
|
|
@@ -415,7 +425,7 @@ export class RealtimeService extends EventEmitter implements RealtimeProvider {
|
|
|
415
425
|
filter: request.filter,
|
|
416
426
|
orderBy: request.orderBy,
|
|
417
427
|
order: request.order,
|
|
418
|
-
limit:
|
|
428
|
+
limit: boundedLimit,
|
|
419
429
|
startAfter: request.startAfter as Record<string, unknown> | undefined,
|
|
420
430
|
searchString: request.searchString
|
|
421
431
|
},
|