@everystack/server 0.4.12 → 0.4.13

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@everystack/server",
3
- "version": "0.4.12",
3
+ "version": "0.4.13",
4
4
  "description": "Server runtime primitives for Lambda — event adapters, routing, SSR, image processing",
5
5
  "license": "AGPL-3.0-only",
6
6
  "author": "Scalable Technology, Inc. <licensing@scalable.technology>",
@@ -159,9 +159,9 @@
159
159
  "sst": "4.13.1",
160
160
  "ts-jest": "29.4.9",
161
161
  "typescript": "5.9.3",
162
- "@everystack/pg-tools": "0.4.1",
163
162
  "@everystack/auth": "0.4.0",
164
- "@everystack/cli": "0.4.28"
163
+ "@everystack/pg-tools": "0.4.1",
164
+ "@everystack/cli": "0.4.29"
165
165
  },
166
166
  "scripts": {
167
167
  "test": "jest",
package/src/provision.ts CHANGED
@@ -20,7 +20,7 @@
20
20
  * without a database; plugin.ts wires the real deps.
21
21
  */
22
22
 
23
- import { generateRoleChainSQL, generateAdminRoleSQL } from './role-chain';
23
+ import { generateRoleChainSQL, generateAdminRoleSQL, generateSearchPathSQL } from './role-chain';
24
24
 
25
25
  export interface ProvisionPayload {
26
26
  /** Password for the least-privilege API login. Alphanumeric only. */
@@ -30,6 +30,13 @@ export interface ProvisionPayload {
30
30
  loginRole?: string;
31
31
  adminRole?: string;
32
32
  appRoles?: string[];
33
+ /**
34
+ * The declared schema set (ordered, deduped, ending in 'public' — declaredSearchPath). When
35
+ * present, provision sets it as the ROLE default (ALTER ROLE … SET search_path) on both login
36
+ * roles, so bare refs resolve without a URL param (the search-path-ownership design). Absent or
37
+ * empty = all-public app, no path set (byte-identical to the pre-search-path-ownership behavior).
38
+ */
39
+ searchPath?: string[];
33
40
  }
34
41
 
35
42
  export interface ProvisionDeps {
@@ -59,7 +66,7 @@ export async function runProvision(
59
66
  payload: ProvisionPayload,
60
67
  deps: ProvisionDeps,
61
68
  ): Promise<ProvisionResult> {
62
- const { authPassword, adminPassword, loginRole, adminRole, appRoles } = payload;
69
+ const { authPassword, adminPassword, loginRole, adminRole, appRoles, searchPath } = payload;
63
70
 
64
71
  // Passwords are generated (RandomPassword special:false / hex) — reject anything else
65
72
  // BEFORE any DDL runs, so nothing untrusted is interpolated into an ALTER ROLE literal.
@@ -89,11 +96,21 @@ export async function runProvision(
89
96
  passwordSet: false,
90
97
  };
91
98
 
99
+ // The role-default search_path (search-path-ownership): set it on the login role now that it
100
+ // exists. The right layer — a clean connection secret, bare refs resolved for every direct
101
+ // connection, and it persists through the API's SET ROLE. No-op for an all-public app.
102
+ const searchPathSql = searchPath ? generateSearchPathSQL(login, searchPath) : null;
103
+ if (searchPathSql) await deps.execute(searchPathSql);
104
+
92
105
  // Admin side FIRST — created and verified before the authenticator swap can happen.
93
106
  if (adminPassword && adminSql) {
94
107
  const admin = adminRole ?? 'migrator';
95
108
  await deps.execute(adminSql);
96
109
  await deps.execute(`ALTER ROLE ${admin} WITH LOGIN PASSWORD '${adminPassword}';`);
110
+ // The operator role gets the same role-default search_path — this is the role db:swap/apply/
111
+ // reconcile --direct connect as, so a clean URL (no ?search_path=) still resolves bare refs.
112
+ const adminSearchPathSql = searchPath ? generateSearchPathSQL(admin, searchPath) : null;
113
+ if (adminSearchPathSql) await deps.execute(adminSearchPathSql);
97
114
  result.adminRole = admin;
98
115
  result.adminPasswordSet = true;
99
116
 
package/src/role-chain.ts CHANGED
@@ -74,6 +74,27 @@ export function generateRoleChainSQL(options: RoleChainOptions = {}): string {
74
74
  return lines.join('\n') + '\n';
75
75
  }
76
76
 
77
+ /**
78
+ * `ALTER ROLE <role> SET search_path = <schemas>` — the role-default search_path
79
+ * (docs/plans/search-path-ownership.md). This is the RIGHT layer for a multi-schema app's
80
+ * bare-reference resolution: it lives on the ROLE, not baked into the connection secret. Every
81
+ * direct connection (psql, pg_restore, local scripts, the `--direct` write lanes) then resolves
82
+ * bare refs with a CLEAN connection URL — no `?search_path=` query param, which libpq rejects as
83
+ * an unknown keyword (the db:swap --direct break). Persists through `SET ROLE` (proven: search_path
84
+ * is a session GUC, SET ROLE does not reset it), so the API's authenticator login carries it to the
85
+ * app roles it switches to. Schema tooling resets to canonical per session (withCanonicalSearchPath),
86
+ * so regclass defaults still render qualified and the role default never causes false drift.
87
+ *
88
+ * Idempotent (ALTER ROLE SET is last-writer-wins). Returns null for an all-public app (no path
89
+ * needed) — byte-identical to today. `searchPath` is the ORDERED, deduped list (declaredSearchPath).
90
+ */
91
+ export function generateSearchPathSQL(role: string, searchPath: string[]): string | null {
92
+ assertIdent(role, 'role');
93
+ if (!searchPath.length) return null;
94
+ searchPath.forEach((s) => assertIdent(s, 'schema'));
95
+ return `ALTER ROLE ${role} SET search_path = ${searchPath.join(', ')};\n`;
96
+ }
97
+
77
98
  export interface AdminRoleOptions {
78
99
  /**
79
100
  * The LOGIN role operator tasks (migrate/seed/DDL/console) connect as — the OTHER half