@everystack/server 0.4.0 → 0.4.2

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.0",
3
+ "version": "0.4.2",
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>",
@@ -76,6 +76,10 @@
76
76
  "./pipeline": {
77
77
  "types": "./src/pipeline.ts",
78
78
  "default": "./src/pipeline.ts"
79
+ },
80
+ "./infra": {
81
+ "types": "./src/infra.ts",
82
+ "default": "./src/infra.ts"
79
83
  }
80
84
  },
81
85
  "peerDependencies": {
@@ -87,6 +91,7 @@
87
91
  "@aws-sdk/signature-v4a": ">=3.1048.0",
88
92
  "@everystack/auth": ">=0.1.0",
89
93
  "@everystack/cli": ">=0.1.0",
94
+ "@everystack/pg-tools": ">=0.4.0",
90
95
  "drizzle-orm": "0.41.0",
91
96
  "heic-decode": "2.1.0",
92
97
  "postgres": "3.4.9",
@@ -106,6 +111,9 @@
106
111
  "sst": {
107
112
  "optional": true
108
113
  },
114
+ "@everystack/pg-tools": {
115
+ "optional": true
116
+ },
109
117
  "@everystack/auth": {
110
118
  "optional": true
111
119
  },
@@ -143,8 +151,9 @@
143
151
  "sst": "4.13.1",
144
152
  "ts-jest": "29.4.9",
145
153
  "typescript": "5.9.3",
154
+ "@everystack/pg-tools": "0.4.0",
146
155
  "@everystack/auth": "0.4.0",
147
- "@everystack/cli": "0.4.5"
156
+ "@everystack/cli": "0.4.8"
148
157
  },
149
158
  "scripts": {
150
159
  "test": "jest",
package/src/db-doctor.ts CHANGED
@@ -87,6 +87,20 @@ export interface DoctorProbe {
87
87
  * an older probe (the check is then skipped). B4.
88
88
  */
89
89
  darkRelations?: DarkRelation[];
90
+ /**
91
+ * pg_dump layer capability: whether @everystack/pg-tools is attached (pg_dump present) and its
92
+ * major can dump this server (client >= server). Drives the one-line "backups" lens — a missing
93
+ * layer blocks db:backup AND the snapshot-first destructive apply. Absent on an older probe (the
94
+ * check is then skipped). See docs/plans/pgdump-layer.md.
95
+ */
96
+ backup?: {
97
+ /** `pg_dump --version` output, or null when the layer is not attached. */
98
+ pgDump: string | null;
99
+ pgDumpMajor: number | null;
100
+ serverMajor: number | null;
101
+ /** pgDumpMajor >= serverMajor — a client can dump an older-or-equal server, never a newer one. */
102
+ compatible: boolean;
103
+ };
90
104
  }
91
105
 
92
106
  /** A function PUBLIC can EXECUTE, and whether it runs as its owner (the dangerous half). */
@@ -324,8 +338,13 @@ export function buildDoctorReport(probe: DoctorProbe, options: DoctorReportOptio
324
338
  remediation:
325
339
  'Run `everystack db:provision --stage <stage>` — it creates the least-privilege role chain on this '
326
340
  + 'database (idempotent, creates no database) AND writes the DATABASE_URL secret for you, without ever '
327
- + 'printing the credential. Then in sst.config.ts declare `new sst.Secret("DatabaseUrl")`, link it to the '
328
- + 'API function, and drop the raw Postgres component from the API link (keep it on ops/worker); redeploy. '
341
+ + 'printing the credential. PRECONDITION before flipping the API: the handler must escalate per request '
342
+ + '(pgSettings + SET ROLE / withRole) — the authenticator holds no privileges of its own (NOINHERIT), so a '
343
+ + 'handler that never escalates cannot even see the schema and every endpoint fails "relation does not exist". '
344
+ + 'Then flip in TWO deploys: (1) declare `new sst.Secret("DatabaseUrl")` and link it to the API function while '
345
+ + 'KEEPING the raw Postgres component linked (an explicit DatabaseUrl already outranks it, and the component is '
346
+ + 'your instant rollback — unlink the secret to revert); verify with real requests and `everystack db:doctor`; '
347
+ + '(2) then drop the raw Postgres component from the API link (keep it on ops/worker) and redeploy. '
329
348
  + 'New apps get all of this automatically from the `everystack.Postgres` construct.',
330
349
  });
331
350
  } else {
@@ -359,7 +378,7 @@ export function buildDoctorReport(probe: DoctorProbe, options: DoctorReportOptio
359
378
  status: accessible ? 'fail' : 'pass',
360
379
  detail: accessible
361
380
  ? `the api connection read "${table}" without setting a role — it carries ambient privileges (likely INHERIT + membership in a USING(true) role). A least-privilege role has no grants of its own and must SET ROLE first.`
362
- : `the api connection cannot read "${table}" without SET ROLE — correct fail-closed behavior`,
381
+ : `the api connection cannot read "${table}" without SET ROLE — correct fail-closed behavior. NOTE the runtime requirement this creates: the handler MUST escalate per request (pgSettings + SET ROLE / withRole); a handler that never escalates fails every endpoint with "relation ... does not exist".`,
363
382
  remediation: accessible
364
383
  ? `Connect the API as a NOINHERIT role with no table grants of its own (e.g. \`authenticator\`), so memberships only apply via SET ROLE: \`ALTER ROLE <api_role> NOINHERIT;\` and revoke any direct table grants. The handler/SSR then escalate per-request via withRole().`
365
384
  : undefined,
@@ -539,6 +558,33 @@ export function buildDoctorReport(probe: DoctorProbe, options: DoctorReportOptio
539
558
  }
540
559
  }
541
560
 
561
+ // Backups lens — the pg_dump layer gates db:backup AND the snapshot-first destructive apply. A
562
+ // missing layer isn't a security hole (warn, not fail), but it silently blocks the ceremony.
563
+ if (probe.backup) {
564
+ const b = probe.backup;
565
+ if (b.pgDump && b.compatible) {
566
+ checks.push({
567
+ name: 'Logical backups available (pg_dump layer)',
568
+ status: 'pass',
569
+ detail: `pg_dump major ${b.pgDumpMajor} attached and compatible with server major ${b.serverMajor} — db:backup and the snapshot-first destructive apply are ready`,
570
+ });
571
+ } else if (b.pgDump && !b.compatible) {
572
+ checks.push({
573
+ name: 'Logical backups available (pg_dump layer)',
574
+ status: 'warn',
575
+ detail: `pg_dump major ${b.pgDumpMajor} is older than server major ${b.serverMajor} — it will refuse to dump (no bypass exists)`,
576
+ remediation: 'Upgrade @everystack/pg-tools to advance the client ceiling to >= the server major, then redeploy.',
577
+ });
578
+ } else {
579
+ checks.push({
580
+ name: 'Logical backups available (pg_dump layer)',
581
+ status: 'warn',
582
+ detail: 'pg_dump layer not attached — db:backup and the snapshot-first destructive apply are unavailable',
583
+ remediation: 'Install @everystack/pg-tools and redeploy (pgDumpLayer attaches it automatically), or pass a bring-your-own layerArn in sst.config.ts.',
584
+ });
585
+ }
586
+ }
587
+
542
588
  return {
543
589
  api,
544
590
  admin,
package/src/infra.ts ADDED
@@ -0,0 +1,96 @@
1
+ /**
2
+ * @everystack/server/infra — deploy-time helpers for a consumer's `sst.config.ts`.
3
+ *
4
+ * `pgDumpLayer()` attaches the PostgreSQL client-tools Lambda layer that `db:backup`/`db:restore`
5
+ * (and the snapshot-first apply ceremony) need. It replaces the old `PGDUMP_LAYER_ARN` env-var line
6
+ * with a zero-config, declarative resource: the framework ships the binaries (@everystack/pg-tools),
7
+ * and this publishes them as a content-addressed LayerVersion into the CONSUMER's own account.
8
+ *
9
+ * Declarative, not imperative: it returns a Pulumi-managed resource's arn (or an escape-hatch
10
+ * string), so the SST/Pulumi engine owns the layer's lifecycle — no SDK side effects at config-eval,
11
+ * no untracked state, clean teardown. Same artifact sha => same layer name => redeploys are no-ops.
12
+ */
13
+
14
+ import { manifest, layerZipPath } from '@everystack/pg-tools';
15
+
16
+ /**
17
+ * SST's ambient config globals, injected so this package typechecks standalone (it must not depend on
18
+ * SST's per-app generated types). In `sst.config.ts` you already have both — pass `{ aws, $util }`.
19
+ * Typed loosely on purpose; the shapes are Pulumi's and not worth vendoring here.
20
+ */
21
+ export interface PgDumpLayerContext {
22
+ /** SST's global `aws` (@pulumi/aws) — for `aws.lambda.LayerVersion`. */
23
+ aws: any;
24
+ /** SST's global `$util` (@pulumi/pulumi) — for `$util.asset.FileArchive`. */
25
+ $util: any;
26
+ }
27
+
28
+ export interface PgDumpLayerOptions {
29
+ /**
30
+ * The stage's PostgreSQL server major. The shipped client must be >= this (pg_dump/pg_restore are
31
+ * backward compatible; a client older than the server is refused). Read it from the engine you declare.
32
+ */
33
+ pgMajor: number;
34
+ /**
35
+ * Escape hatch: attach this exact layer ARN instead of the managed publish. For consumers who build
36
+ * their own (e.g. x86_64, an exotic major) via tools/pgdump-layer. Outranks everything else.
37
+ */
38
+ layerArn?: string;
39
+ }
40
+
41
+ let warnedEnv = false;
42
+
43
+ /**
44
+ * Resolve and attach the pg_dump layer. Returns the ARN to spread into a Function's `layers: []`.
45
+ *
46
+ * Precedence: explicit `layerArn` > `PGDUMP_LAYER_ARN` (deprecated alias, warns once) > the managed
47
+ * content-addressed LayerVersion published into this account.
48
+ *
49
+ * @example
50
+ * import { pgDumpLayer } from '@everystack/server/infra';
51
+ * const ops = new sst.aws.Function('Ops', {
52
+ * layers: [pgDumpLayer({ aws, $util }, 'PgDump', { pgMajor: 16 })],
53
+ * // ...
54
+ * });
55
+ */
56
+ export function pgDumpLayer(
57
+ ctx: PgDumpLayerContext,
58
+ name: string,
59
+ opts: PgDumpLayerOptions,
60
+ ): any /* Input<string> — the layer arn; `any` at the Pulumi interop boundary so it spreads into layers:[] */ {
61
+ // 1) explicit escape hatch wins.
62
+ if (opts.layerArn) return opts.layerArn;
63
+
64
+ // 2) deprecated env alias — still honored for one minor line, but warned.
65
+ const envArn = process.env.PGDUMP_LAYER_ARN;
66
+ if (envArn) {
67
+ if (!warnedEnv) {
68
+ warnedEnv = true;
69
+ console.warn(
70
+ '[everystack] PGDUMP_LAYER_ARN is deprecated and will be removed in the next minor. ' +
71
+ 'Install @everystack/pg-tools and drop the env var (the layer is published automatically), ' +
72
+ 'or pass pgDumpLayer(ctx, name, { layerArn }) for a bring-your-own layer.',
73
+ );
74
+ }
75
+ return envArn;
76
+ }
77
+
78
+ // 3) the managed publish: a content-addressed LayerVersion in the consumer's own account.
79
+ if (manifest.major < opts.pgMajor) {
80
+ throw new Error(
81
+ `@everystack/pg-tools ships pg${manifest.major} client tools, but this stage's server is ` +
82
+ `pg${opts.pgMajor}. pg_dump/pg_restore must be >= the server. Upgrade @everystack/pg-tools ` +
83
+ `to advance the client ceiling, or pass a bring-your-own layerArn.`,
84
+ );
85
+ }
86
+ const sha8 = manifest.sha256.slice(0, 8);
87
+ const layer = new ctx.aws.lambda.LayerVersion(name, {
88
+ // content-addressed: same artifact => same name => Pulumi treats redeploys as no-ops.
89
+ layerName: `everystack-pgdump${manifest.major}-${sha8}`,
90
+ code: new ctx.$util.asset.FileArchive(layerZipPath()),
91
+ compatibleRuntimes: ['nodejs20.x'],
92
+ compatibleArchitectures: [manifest.arch],
93
+ description: `everystack pg_dump/pg_restore — pg${manifest.postgresql}, sha ${sha8}`,
94
+ });
95
+ return layer.arn;
96
+ }
package/src/plugin.ts CHANGED
@@ -725,8 +725,34 @@ export function dbPlugin(options: DbPluginOptions): Plugin {
725
725
  }
726
726
  }
727
727
 
728
+ // Backups lens — is the pg_dump layer attached AND version-compatible? Reuse the
729
+ // db:backup:probe internals; a missing layer leaves pgDump null (the lens warns), never throws.
730
+ let backup: import('./db-doctor').DoctorProbe['backup'];
731
+ try {
732
+ const { pgClientVersion, parsePgDumpMajor, serverMajorFromNum, isDumpCompatible } = await import('./backup');
733
+ let pgDump: string | null = null;
734
+ let pgDumpMajor: number | null = null;
735
+ try {
736
+ pgDump = await pgClientVersion('pg_dump');
737
+ pgDumpMajor = parsePgDumpMajor(pgDump);
738
+ } catch {
739
+ // layer absent — pgDump stays null, the lens reports "not attached"
740
+ }
741
+ const svnRow = rowsOf(await opsDb.execute(sql.raw('SHOW server_version_num')))[0];
742
+ const svn = Number(svnRow?.server_version_num);
743
+ const serverMajor = Number.isFinite(svn) ? serverMajorFromNum(svn) : null;
744
+ backup = {
745
+ pgDump,
746
+ pgDumpMajor,
747
+ serverMajor,
748
+ compatible: pgDumpMajor != null && Number.isFinite(svn) ? isDumpCompatible(pgDumpMajor, svn) : false,
749
+ };
750
+ } catch {
751
+ // leave backup undefined → the lens is skipped, exactly like the other optional probes
752
+ }
753
+
728
754
  return buildDoctorReport(
729
- { api, admin, forceRls, appGrantedRls, apiAmbientRead, secdefFunctions, publicExecutableFunctions, darkRelations },
755
+ { api, admin, forceRls, appGrantedRls, apiAmbientRead, secdefFunctions, publicExecutableFunctions, darkRelations, backup },
730
756
  { forceRlsCarveouts: options.forceRlsCarveouts, functionSchemas: options.functionSchemas },
731
757
  );
732
758
  };
@@ -794,7 +820,7 @@ export function dbPlugin(options: DbPluginOptions): Plugin {
794
820
  try {
795
821
  pgDump = await pgClientVersion('pg_dump');
796
822
  } catch (err: any) {
797
- return { error: `pg_dump not available — is the pg_dump layer attached to this function? (${err?.message || String(err)})` };
823
+ return { error: `pg_dump not available — the pg_dump layer is not attached. Install @everystack/pg-tools and redeploy (pgDumpLayer() attaches it automatically), or pass a bring-your-own layerArn in sst.config.ts. (${err?.message || String(err)})` };
798
824
  }
799
825
  const pgDumpMajor = parsePgDumpMajor(pgDump);
800
826
 
package/src/role-chain.ts CHANGED
@@ -85,13 +85,18 @@ export interface AdminRoleOptions {
85
85
  }
86
86
 
87
87
  /**
88
- * Generate idempotent DDL for the operator (admin) login role. Membership is granted BOTH
89
- * ways with the CURRENT user (the master/owner running the provision): owner → admin gives
90
- * the admin ownership rights over every owner-created object (no REASSIGN OWNED), and
91
- * admin → owner keeps the owner able to manage objects the admin creates later. The grants
92
- * are guarded so a RE-provision running AS the admin role (post-split, the ops connection
93
- * is this role) never attempts the self-grant Postgres hard-errors on. Does NOT set a
94
- * password — that's `db:provision`'s job, from a deploy-generated value.
88
+ * Generate idempotent DDL for the operator (admin) login role. Membership is granted ONE
89
+ * way — owner → admin — giving the admin ownership rights over every owner-created object
90
+ * (no REASSIGN OWNED). Never the reverse: Postgres forbids circular role membership, and
91
+ * the 0.4.0 reciprocal grant broke the SECOND stage on a shared RDS instance (roles are
92
+ * INSTANCE-level; the first stage's `migrator ∈ owner` membership made the reciprocal a
93
+ * cycle — 'role "migrator" is a member of role "postgres"').
94
+ *
95
+ * Adoption over creation: the grant is skipped when the membership already exists (a
96
+ * prior stage or a manual setup made it) and when granting would FORM a cycle (the owner
97
+ * is already a member of the admin role in some prior topology). A re-provision running
98
+ * AS the admin role (post-split, the ops connection is this role) skips the self-grant
99
+ * Postgres hard-errors on. Does NOT set a password — that's `db:provision`'s job.
95
100
  */
96
101
  export function generateAdminRoleSQL(options: AdminRoleOptions = {}): string {
97
102
  const adminRole = options.adminRole ?? 'migrator';
@@ -100,7 +105,7 @@ export function generateAdminRoleSQL(options: AdminRoleOptions = {}): string {
100
105
  const lines: string[] = [
101
106
  `DO $$ BEGIN IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = '${adminRole}') THEN CREATE ROLE ${adminRole} LOGIN INHERIT NOBYPASSRLS; END IF; END $$;`,
102
107
  `ALTER ROLE ${adminRole} WITH LOGIN INHERIT NOBYPASSRLS;`,
103
- `DO $$ BEGIN IF current_user <> '${adminRole}' THEN EXECUTE format('GRANT %I TO ${adminRole}', current_user); EXECUTE format('GRANT ${adminRole} TO %I', current_user); END IF; END $$;`,
108
+ `DO $$ BEGIN IF current_user <> '${adminRole}' AND NOT pg_has_role('${adminRole}', current_user, 'MEMBER') AND NOT pg_has_role(current_user, '${adminRole}', 'MEMBER') THEN EXECUTE format('GRANT %I TO ${adminRole}', current_user); END IF; END $$;`,
104
109
  ];
105
110
 
106
111
  return lines.join('\n') + '\n';