@everystack/server 0.4.11 → 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 +3 -3
- package/src/db.ts +19 -0
- package/src/plugin.ts +74 -0
- package/src/provision.ts +19 -2
- package/src/role-chain.ts +21 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@everystack/server",
|
|
3
|
-
"version": "0.4.
|
|
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/cli": "0.4.25",
|
|
163
162
|
"@everystack/auth": "0.4.0",
|
|
164
|
-
"@everystack/pg-tools": "0.4.
|
|
163
|
+
"@everystack/pg-tools": "0.4.1",
|
|
164
|
+
"@everystack/cli": "0.4.29"
|
|
165
165
|
},
|
|
166
166
|
"scripts": {
|
|
167
167
|
"test": "jest",
|
package/src/db.ts
CHANGED
|
@@ -110,6 +110,25 @@ export function getAdminDatabaseUrl(): string | null {
|
|
|
110
110
|
return null;
|
|
111
111
|
}
|
|
112
112
|
|
|
113
|
+
/**
|
|
114
|
+
* The OPERATOR connection the privileged write lanes run on: the provisioned admin
|
|
115
|
+
* (migrator/owner) credential when present, else the linked SST Postgres master. Never the
|
|
116
|
+
* least-privilege authenticator (`DATABASE_URL`) — these lanes run DDL the authenticator
|
|
117
|
+
* cannot. Reports which source won so the caller can log the venue (not the credential).
|
|
118
|
+
*
|
|
119
|
+
* This is the resolution the `--direct` write lanes and `db:swap --stage` share (the
|
|
120
|
+
* stage-write-lanes foundation): the ops action returns this URL to an IAM-gated caller,
|
|
121
|
+
* which holds it in process memory and never prints it. Returns null when no operator
|
|
122
|
+
* connection is configured at all.
|
|
123
|
+
*/
|
|
124
|
+
export function getOperatorDatabaseUrl(): { url: string; source: 'admin' | 'master' } | null {
|
|
125
|
+
const admin = getAdminDatabaseUrl();
|
|
126
|
+
if (admin) return { url: admin, source: 'admin' };
|
|
127
|
+
const master = getMasterDatabaseUrl();
|
|
128
|
+
if (master) return { url: master, source: 'master' };
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
|
|
113
132
|
export function getJwtSecret(): Uint8Array {
|
|
114
133
|
// PascalCase Secret (SST_RESOURCE_JwtSecret → { value })
|
|
115
134
|
const pascal = sstLink('JwtSecret')?.value as string | undefined;
|
package/src/plugin.ts
CHANGED
|
@@ -625,6 +625,25 @@ export function dbPlugin(options: DbPluginOptions): Plugin {
|
|
|
625
625
|
return { host: db.host, port: db.port, database: db.database, username: db.username, password: db.password };
|
|
626
626
|
};
|
|
627
627
|
|
|
628
|
+
// --- db:operator-url ---
|
|
629
|
+
// The stage-write-lanes foundation (brick 1): resolve the OPERATOR connection
|
|
630
|
+
// (admin/migrator, else the linked master — never the least-privilege authenticator)
|
|
631
|
+
// and return it to the IAM-gated caller. The CLI's `--direct` write verbs and
|
|
632
|
+
// `db:swap --stage` invoke this, hold the URL in process memory, execute CLI-side with
|
|
633
|
+
// an unbounded clock, and never print it. The URL travels only in the Lambda invoke
|
|
634
|
+
// response — the same in-process channel db:psql uses for its components.
|
|
635
|
+
actions['db:operator-url'] = async () => {
|
|
636
|
+
const { getOperatorDatabaseUrl } = await import('./db');
|
|
637
|
+
const operator = getOperatorDatabaseUrl();
|
|
638
|
+
if (!operator) {
|
|
639
|
+
return {
|
|
640
|
+
error:
|
|
641
|
+
'no operator connection available — provision an admin credential (ADMIN_DATABASE_URL / AdminDatabaseUrl) or link an sst.aws.Postgres component for the master fallback.',
|
|
642
|
+
};
|
|
643
|
+
}
|
|
644
|
+
return { url: operator.url, source: operator.source };
|
|
645
|
+
};
|
|
646
|
+
|
|
628
647
|
// --- db:query ---
|
|
629
648
|
actions['db:query'] = async (payload, _ctx) => {
|
|
630
649
|
const { sql: querySql } = payload as { sql: string };
|
|
@@ -1015,6 +1034,61 @@ export function dbPlugin(options: DbPluginOptions): Plugin {
|
|
|
1015
1034
|
}
|
|
1016
1035
|
};
|
|
1017
1036
|
|
|
1037
|
+
// --- db:backfill — the one-shot data-job lane, stage-side (stage-write-lanes brick 4) ---
|
|
1038
|
+
// db:backfill was direct-connection only: the sanctioned agent path for a committed,
|
|
1039
|
+
// content-addressed, idempotent data job ended at "paste SQL into psql". This runs the
|
|
1040
|
+
// same backfill core on the operator connection: the CLI ships the *.sql files, this plans
|
|
1041
|
+
// (by content identity, against everystack.backfill_log), and under apply runs the pending
|
|
1042
|
+
// jobs in order and records each. A least-privileged operator never holds a URL for a small
|
|
1043
|
+
// data job; the --direct lane (CLI-side) is the escape for jobs too big for the 900s clock.
|
|
1044
|
+
actions['db:backfill'] = async (payload, _ctx) => {
|
|
1045
|
+
const p = (payload ?? {}) as {
|
|
1046
|
+
files?: { file: string; sql: string }[];
|
|
1047
|
+
apply?: boolean;
|
|
1048
|
+
markApplied?: string;
|
|
1049
|
+
actor?: string | null;
|
|
1050
|
+
gitRef?: string | null;
|
|
1051
|
+
};
|
|
1052
|
+
try {
|
|
1053
|
+
const {
|
|
1054
|
+
planBackfills, readBackfillLog, executeBackfills, markBackfillApplied,
|
|
1055
|
+
} = await import('@everystack/cli/backfill');
|
|
1056
|
+
const { sql } = await import('drizzle-orm');
|
|
1057
|
+
const runner = async (query: string): Promise<any[]> => {
|
|
1058
|
+
const result: any = await opsDb.execute(sql.raw(query));
|
|
1059
|
+
return Array.isArray(result) ? result : result?.rows ?? [];
|
|
1060
|
+
};
|
|
1061
|
+
const files = p.files ?? [];
|
|
1062
|
+
const opts = { actor: p.actor ?? null, gitRef: p.gitRef ?? null };
|
|
1063
|
+
|
|
1064
|
+
// Adopt hatch: record a file as applied WITHOUT running it.
|
|
1065
|
+
if (p.markApplied) {
|
|
1066
|
+
const file = files.find((f) => f.file.endsWith(`/${p.markApplied}`) || f.file === p.markApplied);
|
|
1067
|
+
if (!file) return { error: `--mark-applied names a file not shipped: ${p.markApplied}` };
|
|
1068
|
+
const identity = await markBackfillApplied(runner, file as any, opts);
|
|
1069
|
+
return { markedApplied: file.file, identity };
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
const plan = await (async () => planBackfills(files as any, await readBackfillLog(runner)))();
|
|
1073
|
+
const planOut = {
|
|
1074
|
+
applied: plan.applied,
|
|
1075
|
+
pending: plan.pending.map(({ sql: _sql, ...rest }: any) => rest),
|
|
1076
|
+
blocked: plan.blocked,
|
|
1077
|
+
};
|
|
1078
|
+
if (!p.apply) return { plan: planOut, applied: false };
|
|
1079
|
+
|
|
1080
|
+
const result = await executeBackfills(runner, plan, opts);
|
|
1081
|
+
return {
|
|
1082
|
+
plan: planOut,
|
|
1083
|
+
applied: true,
|
|
1084
|
+
ran: result.ran,
|
|
1085
|
+
failed: result.failed ?? null,
|
|
1086
|
+
};
|
|
1087
|
+
} catch (err: any) {
|
|
1088
|
+
return { error: err?.message || String(err) };
|
|
1089
|
+
}
|
|
1090
|
+
};
|
|
1091
|
+
|
|
1018
1092
|
// --- db:refresh — the credential-free DATA lane's refresh half (the reconcile-lane twin) ---
|
|
1019
1093
|
// db:reconcile deploys the derived DEFINITION but no-ops on an unchanged def, so a pure
|
|
1020
1094
|
// DATA-changed matview refresh had no credential-free path. This runs the refresh HERE on
|
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
|