@palbase/backend 9.0.0 → 10.0.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.
package/dist/index.cjs CHANGED
@@ -29,6 +29,7 @@ __export(src_exports, {
29
29
  Database: () => Database,
30
30
  Delete: () => Delete,
31
31
  Documents: () => Documents,
32
+ EGRESS_CONFIG_KIND: () => EGRESS_CONFIG_KIND,
32
33
  EXTENSION_DEPENDENCIES: () => EXTENSION_DEPENDENCIES,
33
34
  FLAGS_CONFIG_KIND: () => FLAGS_CONFIG_KIND,
34
35
  Flags: () => Flags,
@@ -59,6 +60,7 @@ __export(src_exports, {
59
60
  Resource: () => Resource,
60
61
  STORAGE_CONFIG_KIND: () => STORAGE_CONFIG_KIND,
61
62
  Storage: () => Storage,
63
+ TEST_USERS_CONFIG_KIND: () => TEST_USERS_CONFIG_KIND,
62
64
  TooManyRequests: () => TooManyRequests,
63
65
  TraceId: () => TraceId,
64
66
  Unauthorized: () => Unauthorized,
@@ -77,6 +79,7 @@ __export(src_exports, {
77
79
  boolean: () => boolean,
78
80
  bucket: () => bucket,
79
81
  buildProvider: () => buildProvider,
82
+ defineEgress: () => defineEgress,
80
83
  defineError: () => defineError,
81
84
  defineFlags: () => defineFlags,
82
85
  defineJob: () => defineJob,
@@ -84,12 +87,14 @@ __export(src_exports, {
84
87
  defineNotifications: () => defineNotifications,
85
88
  defineSchema: () => defineSchema,
86
89
  defineStorage: () => defineStorage,
90
+ defineTestUsers: () => defineTestUsers,
87
91
  defineWebhook: () => defineWebhook,
88
92
  defineWorker: () => defineWorker,
89
93
  documents: () => documents,
90
94
  enumType: () => enumType,
91
95
  flag: () => flag,
92
96
  getErrorRegistry: () => getErrorRegistry,
97
+ getRoutes: () => getRoutes,
93
98
  integer: () => integer,
94
99
  isPalbaseExtension: () => isPalbaseExtension,
95
100
  jsonb: () => jsonb,
@@ -102,6 +107,7 @@ __export(src_exports, {
102
107
  recordThrows: () => recordThrows,
103
108
  reservedSecretKey: () => reservedSecretKey,
104
109
  storage: () => storage,
110
+ testUser: () => testUser,
105
111
  text: () => text,
106
112
  timestamp: () => timestamp,
107
113
  uuid: () => uuid,
@@ -402,12 +408,44 @@ var ColumnBuilder = class _ColumnBuilder {
402
408
  * `auth.users` lives in the SAME tenant database (palauth-owned), so this is
403
409
  * a genuine cross-schema integrity constraint scoped to THIS tenant's users.
404
410
  * The referenced `auth.users.id` is `text` (palauth ids are `usr_<uuid>`), so
405
- * the referencing column must be `text()` too. Combine with `.onDelete(...)`
406
- * for cascade behaviour, e.g. a `user_id` column defined as
407
- * `text().notNull().referencesAuthUser().onDelete("cascade")`.
411
+ * the referencing column must be `text()` too.
412
+ *
413
+ * ON DELETE is REQUIRED here and may only be `cascade` or `set null`: an
414
+ * account-erasure request must never be blocked by a lingering FK, so
415
+ * `restrict` / `no action` are not accepted (they don't type-check). Example:
416
+ * `text().notNull().referencesAuthUser("cascade")`, or
417
+ * `text().nullable().referencesAuthUser("set null")`. The server
418
+ * (validateAuthUserFK) enforces this — and the remaining rules the type can't
419
+ * express (referencing column is text, `set null` needs a nullable column) —
420
+ * as the real boundary; this signature is the compile-time DX mirror.
408
421
  */
409
- referencesAuthUser() {
410
- return this.references("auth.users", "id");
422
+ referencesAuthUser(onDelete) {
423
+ this._def.references = { table: "auth.users", column: "id" };
424
+ this._def.onDeleteAction = onDelete;
425
+ return new _ColumnBuilder(this._def.type, this._def);
426
+ }
427
+ /**
428
+ * Add a real DB-level foreign key to the canonical, server-minted installation
429
+ * anchor (`REFERENCES auth.installations(id)`) — the app-scoped verified-device
430
+ * root (`ins_...`). Sugar for `.references("auth.installations", "id")`.
431
+ *
432
+ * An installation is an APP INSTALL, not a user: this FK is NOT user ownership.
433
+ * A user-owned row STILL needs its own `.referencesAuthUser(...)` FK so account
434
+ * erasure removes it — an installation reference alone does not tie a row to a
435
+ * user's deletion. Use this only for install-scoped state (device prefs, push
436
+ * routing, …), alongside a separate auth-user FK where the row is user-owned.
437
+ *
438
+ * `auth.installations` lives in the SAME tenant DB (palauth-owned); its `id` is
439
+ * `text` (`ins_<uuid>`), so the referencing column must be `text()` too. ON
440
+ * DELETE is REQUIRED and may only be `cascade` or `set null` (same allowed set
441
+ * as an auth-user FK): an installation revoke / orphan cleanup must never be
442
+ * blocked by a lingering FK. The server (validateAuthAnchorFK) is the real
443
+ * boundary; this signature is the compile-time DX mirror.
444
+ */
445
+ referencesInstallation(onDelete) {
446
+ this._def.references = { table: "auth.installations", column: "id" };
447
+ this._def.onDeleteAction = onDelete;
448
+ return new _ColumnBuilder(this._def.type, this._def);
411
449
  }
412
450
  /** Set the ON DELETE action for a foreign key reference. */
413
451
  onDelete(action) {
@@ -515,7 +553,50 @@ function rowType(def) {
515
553
  function optionalOnInsert(def) {
516
554
  return def.nullable === true || def.defaultRandom === true || def.defaultNow === true || def.defaultValue !== void 0;
517
555
  }
518
- function tableBlock(table, indent) {
556
+ var AUTH_USERS_TABLE = "auth.users";
557
+ function ownerColumnOf(table) {
558
+ for (const [col, builder] of Object.entries(table.columns)) {
559
+ if (builder._def.references?.table === AUTH_USERS_TABLE) return col;
560
+ }
561
+ return null;
562
+ }
563
+ function buildChildEdges(schema) {
564
+ const edges = /* @__PURE__ */ new Map();
565
+ for (const name of Object.keys(schema.tables)) edges.set(name, []);
566
+ for (const childName of Object.keys(schema.tables)) {
567
+ const table = schema.tables[childName];
568
+ if (table === void 0) continue;
569
+ for (const [col, builder] of Object.entries(table.columns)) {
570
+ const ref = builder._def.references;
571
+ if (ref === void 0) continue;
572
+ if (ref.table === childName) continue;
573
+ const bucket2 = edges.get(ref.table);
574
+ if (bucket2 === void 0) continue;
575
+ if (bucket2.some((e) => e.child === childName)) continue;
576
+ bucket2.push({ child: childName, column: col });
577
+ }
578
+ }
579
+ return edges;
580
+ }
581
+ function pruneCycles(raw2) {
582
+ const pruned = /* @__PURE__ */ new Map();
583
+ for (const table of raw2.keys()) pruned.set(table, []);
584
+ const state = /* @__PURE__ */ new Map();
585
+ function visit(table) {
586
+ state.set(table, "visiting");
587
+ for (const edge of raw2.get(table) ?? []) {
588
+ if (state.get(edge.child) === "visiting") continue;
589
+ pruned.get(table)?.push(edge);
590
+ if (state.get(edge.child) === void 0) visit(edge.child);
591
+ }
592
+ state.set(table, "done");
593
+ }
594
+ for (const table of raw2.keys()) {
595
+ if (state.get(table) === void 0) visit(table);
596
+ }
597
+ return pruned;
598
+ }
599
+ function tableBlock(table, children, indent) {
519
600
  const cols = Object.entries(table.columns);
520
601
  const rowLines = cols.map(([col, builder]) => {
521
602
  return `${indent} ${col}: ${rowType(builder._def)};`;
@@ -525,6 +606,8 @@ function tableBlock(table, indent) {
525
606
  const opt = optionalOnInsert(def) ? "?" : "";
526
607
  return `${indent} ${col}${opt}: ${rowType(def)};`;
527
608
  });
609
+ const owner = ownerColumnOf(table);
610
+ const childEntries = children.map((e) => `${JSON.stringify(e.child)}: ${JSON.stringify(e.column)}`).join("; ");
528
611
  return [
529
612
  `${indent}${table.name}: {`,
530
613
  `${indent} row: {`,
@@ -533,12 +616,17 @@ function tableBlock(table, indent) {
533
616
  `${indent} insert: {`,
534
617
  ...insertLines,
535
618
  `${indent} };`,
619
+ `${indent} owner: ${owner === null ? "null" : JSON.stringify(owner)};`,
620
+ `${indent} children: {${childEntries === "" ? "" : ` ${childEntries} `}};`,
536
621
  `${indent}};`
537
622
  ].join("\n");
538
623
  }
539
624
  function makeEnvDts(schema) {
540
625
  const tableNames = Object.keys(schema.tables);
541
- const blocks = tableNames.map((name) => tableBlock(schema.tables[name], " "));
626
+ const children = pruneCycles(buildChildEdges(schema));
627
+ const blocks = tableNames.map(
628
+ (name) => tableBlock(schema.tables[name], children.get(name) ?? [], " ")
629
+ );
542
630
  const body = blocks.length > 0 ? `
543
631
  ${blocks.join("\n")}
544
632
  ` : "";
@@ -633,6 +721,22 @@ function defineStorage(input) {
633
721
  return { __config: STORAGE_CONFIG_KIND, buckets };
634
722
  }
635
723
 
724
+ // src/config/egress.ts
725
+ var EGRESS_CONFIG_KIND = "egress";
726
+ function defineEgress(input) {
727
+ if (input === null || typeof input !== "object" || !Array.isArray(input.hosts)) {
728
+ throw new Error("defineEgress expects { hosts: string[] }");
729
+ }
730
+ const hosts = [];
731
+ for (const h of input.hosts) {
732
+ if (typeof h !== "string" || h.trim().length === 0) {
733
+ throw new Error("defineEgress: every host must be a non-empty string");
734
+ }
735
+ hosts.push(h.trim().toLowerCase());
736
+ }
737
+ return { __config: EGRESS_CONFIG_KIND, hosts };
738
+ }
739
+
636
740
  // src/config/notifications.ts
637
741
  var NOTIFICATIONS_CONFIG_KIND = "notifications";
638
742
  var RESERVED_SECRET_PREFIX = "PB_NOTIFICATIONS";
@@ -744,6 +848,98 @@ function camelToUpperSnake(s) {
744
848
  return s.replace(/([a-z0-9])([A-Z])/g, "$1_$2").toUpperCase();
745
849
  }
746
850
 
851
+ // src/config/test-users.ts
852
+ var TEST_USERS_CONFIG_KIND = "test-users";
853
+ var TEST_USER_NAME_RE = /^[a-zA-Z][a-zA-Z0-9_]*$/;
854
+ var EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
855
+ var MIN_PASSWORD_LENGTH = 8;
856
+ function isPlainObject(value) {
857
+ return typeof value === "object" && value !== null && !Array.isArray(value);
858
+ }
859
+ function normalizeSeed(seed, userName) {
860
+ if (seed === void 0) return {};
861
+ if (!isPlainObject(seed)) {
862
+ throw new Error(`testUser ${JSON.stringify(userName)}: seed must be { <table>: [ {...} ] }`);
863
+ }
864
+ const out = {};
865
+ for (const [table, rows] of Object.entries(seed)) {
866
+ if (!Array.isArray(rows)) {
867
+ throw new Error(
868
+ `testUser ${JSON.stringify(userName)}: seed.${table} must be an array of rows`
869
+ );
870
+ }
871
+ if (rows.length === 0) {
872
+ throw new Error(
873
+ `testUser ${JSON.stringify(userName)}: seed.${table} is empty \u2014 remove it or add a row`
874
+ );
875
+ }
876
+ for (const row of rows) {
877
+ if (!isPlainObject(row)) {
878
+ throw new Error(
879
+ `testUser ${JSON.stringify(userName)}: every row in seed.${table} must be an object`
880
+ );
881
+ }
882
+ }
883
+ out[table] = rows;
884
+ }
885
+ return out;
886
+ }
887
+ function testUser(opts = {}) {
888
+ if (!isPlainObject(opts)) {
889
+ throw new Error("testUser() expects { email?, password?, seed? }");
890
+ }
891
+ const hasEmail = opts.email !== void 0;
892
+ const hasPassword = opts.password !== void 0;
893
+ if (hasEmail !== hasPassword) {
894
+ throw new Error(
895
+ "testUser() needs email AND password together (a fixture with stable credentials) or neither (a template the server generates credentials for)"
896
+ );
897
+ }
898
+ let email = null;
899
+ let password = null;
900
+ if (hasEmail) {
901
+ if (typeof opts.email !== "string" || !EMAIL_RE.test(opts.email)) {
902
+ throw new Error(`testUser email ${JSON.stringify(opts.email)} is not a valid address`);
903
+ }
904
+ if (typeof opts.password !== "string" || opts.password.length < MIN_PASSWORD_LENGTH) {
905
+ throw new Error(
906
+ `testUser password must be at least ${MIN_PASSWORD_LENGTH} characters (got ${typeof opts.password === "string" ? opts.password.length : 0})`
907
+ );
908
+ }
909
+ email = opts.email;
910
+ password = opts.password;
911
+ }
912
+ return { email, password, seed: normalizeSeed(opts.seed, email ?? "<template>") };
913
+ }
914
+ function defineTestUsers(input) {
915
+ if (!isPlainObject(input) || !isPlainObject(input.users)) {
916
+ throw new Error("defineTestUsers expects { users: { <name>: testUser({...}) } }");
917
+ }
918
+ const users = {};
919
+ const emails = /* @__PURE__ */ new Map();
920
+ for (const name of Object.keys(input.users)) {
921
+ if (!TEST_USER_NAME_RE.test(name)) {
922
+ throw new Error(
923
+ `test user name ${JSON.stringify(name)} is invalid \u2014 must start with a letter and contain only letters, digits, and underscores`
924
+ );
925
+ }
926
+ const def = input.users[name];
927
+ if (def === void 0) continue;
928
+ if (def.email !== null) {
929
+ const lower = def.email.toLowerCase();
930
+ const already = emails.get(lower);
931
+ if (already !== void 0) {
932
+ throw new Error(
933
+ `test users ${JSON.stringify(already)} and ${JSON.stringify(name)} declare the same e-mail ${JSON.stringify(def.email)}`
934
+ );
935
+ }
936
+ emails.set(lower, name);
937
+ }
938
+ users[name] = def;
939
+ }
940
+ return { __config: TEST_USERS_CONFIG_KIND, users };
941
+ }
942
+
747
943
  // src/config/flags.ts
748
944
  var FLAGS_CONFIG_KIND = "flags";
749
945
  var FLAG_KEY_RE = /^[a-zA-Z][a-zA-Z0-9_]*$/;
@@ -916,10 +1112,42 @@ function recordThrows(target, fnName, throws) {
916
1112
  const throwsBuffer = carrier[THROWS_BUFFER];
917
1113
  if (throwsBuffer) throwsBuffer[fnName] = throws;
918
1114
  }
1115
+ function getRoutes(ctor) {
1116
+ const carrier = carrierOf(ctor);
1117
+ const routes = carrier[ROUTES] ?? [];
1118
+ const returnBuffer = carrier[RETURN_BUFFER];
1119
+ if (returnBuffer) {
1120
+ for (const route of routes) {
1121
+ const buffered = returnBuffer[route.fnName];
1122
+ if (buffered && route.returnSchema === void 0) {
1123
+ route.returnSchema = buffered;
1124
+ }
1125
+ }
1126
+ }
1127
+ const throwsBuffer = carrier[THROWS_BUFFER];
1128
+ if (throwsBuffer) {
1129
+ for (const route of routes) {
1130
+ const buffered = throwsBuffer[route.fnName];
1131
+ if (buffered && route.throws === void 0) {
1132
+ route.throws = buffered;
1133
+ }
1134
+ }
1135
+ }
1136
+ return routes.map((r) => ({
1137
+ ...r,
1138
+ params: r.params.slice(),
1139
+ ...r.throws !== void 0 ? { throws: r.throws.slice() } : {}
1140
+ }));
1141
+ }
919
1142
 
920
1143
  // src/decorators/methods.ts
921
1144
  function makeMethodDecorator(method) {
922
1145
  return function(subpath, options = {}) {
1146
+ if (typeof subpath !== "string") {
1147
+ throw new Error(
1148
+ `@${method[0]}${method.slice(1).toLowerCase()}(subpath) expects a string subpath, got ${typeof subpath}.` + (method === "QUERY" ? " If this is a zod schema on a method parameter: the query-string param decorator was renamed @QueryParams(schema) in @palbase/backend 9.0.0." : "")
1149
+ );
1150
+ }
923
1151
  return function(target, propertyKey) {
924
1152
  recordRoute(target, String(propertyKey), method, subpath, options);
925
1153
  };
@@ -1519,6 +1747,7 @@ var import_zod2 = require("zod");
1519
1747
  Database,
1520
1748
  Delete,
1521
1749
  Documents,
1750
+ EGRESS_CONFIG_KIND,
1522
1751
  EXTENSION_DEPENDENCIES,
1523
1752
  FLAGS_CONFIG_KIND,
1524
1753
  Flags,
@@ -1549,6 +1778,7 @@ var import_zod2 = require("zod");
1549
1778
  Resource,
1550
1779
  STORAGE_CONFIG_KIND,
1551
1780
  Storage,
1781
+ TEST_USERS_CONFIG_KIND,
1552
1782
  TooManyRequests,
1553
1783
  TraceId,
1554
1784
  Unauthorized,
@@ -1567,6 +1797,7 @@ var import_zod2 = require("zod");
1567
1797
  boolean,
1568
1798
  bucket,
1569
1799
  buildProvider,
1800
+ defineEgress,
1570
1801
  defineError,
1571
1802
  defineFlags,
1572
1803
  defineJob,
@@ -1574,12 +1805,14 @@ var import_zod2 = require("zod");
1574
1805
  defineNotifications,
1575
1806
  defineSchema,
1576
1807
  defineStorage,
1808
+ defineTestUsers,
1577
1809
  defineWebhook,
1578
1810
  defineWorker,
1579
1811
  documents,
1580
1812
  enumType,
1581
1813
  flag,
1582
1814
  getErrorRegistry,
1815
+ getRoutes,
1583
1816
  integer,
1584
1817
  isPalbaseExtension,
1585
1818
  jsonb,
@@ -1592,6 +1825,7 @@ var import_zod2 = require("zod");
1592
1825
  recordThrows,
1593
1826
  reservedSecretKey,
1594
1827
  storage,
1828
+ testUser,
1595
1829
  text,
1596
1830
  timestamp,
1597
1831
  uuid,