@geekmidas/db 10.0.0-alpha.16 → 10.0.0-alpha.161

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.
Files changed (90) hide show
  1. package/README.md +17 -0
  2. package/dist/introspect/DataBrowser.cjs +117 -0
  3. package/dist/introspect/DataBrowser.cjs.map +1 -0
  4. package/dist/introspect/DataBrowser.d.cts +51 -0
  5. package/dist/introspect/DataBrowser.d.cts.map +1 -0
  6. package/dist/introspect/DataBrowser.d.mts +51 -0
  7. package/dist/introspect/DataBrowser.d.mts.map +1 -0
  8. package/dist/introspect/DataBrowser.mjs +116 -0
  9. package/dist/introspect/DataBrowser.mjs.map +1 -0
  10. package/dist/introspect/errors.cjs +48 -0
  11. package/dist/introspect/errors.cjs.map +1 -0
  12. package/dist/introspect/errors.d.cts +27 -0
  13. package/dist/introspect/errors.d.cts.map +1 -0
  14. package/dist/introspect/errors.d.mts +27 -0
  15. package/dist/introspect/errors.d.mts.map +1 -0
  16. package/dist/introspect/errors.mjs +45 -0
  17. package/dist/introspect/errors.mjs.map +1 -0
  18. package/dist/introspect/filtering.cjs +101 -0
  19. package/dist/introspect/filtering.cjs.map +1 -0
  20. package/dist/introspect/filtering.d.cts +29 -0
  21. package/dist/introspect/filtering.d.cts.map +1 -0
  22. package/dist/introspect/filtering.d.mts +29 -0
  23. package/dist/introspect/filtering.d.mts.map +1 -0
  24. package/dist/introspect/filtering.mjs +98 -0
  25. package/dist/introspect/filtering.mjs.map +1 -0
  26. package/dist/introspect/handler.cjs +159 -0
  27. package/dist/introspect/handler.cjs.map +1 -0
  28. package/dist/introspect/handler.d.cts +43 -0
  29. package/dist/introspect/handler.d.cts.map +1 -0
  30. package/dist/introspect/handler.d.mts +43 -0
  31. package/dist/introspect/handler.d.mts.map +1 -0
  32. package/dist/introspect/handler.mjs +158 -0
  33. package/dist/introspect/handler.mjs.map +1 -0
  34. package/dist/introspect/index.cjs +22 -0
  35. package/dist/introspect/index.d.cts +8 -0
  36. package/dist/introspect/index.d.mts +8 -0
  37. package/dist/introspect/index.mjs +8 -0
  38. package/dist/introspect/introspection.cjs +149 -0
  39. package/dist/introspect/introspection.cjs.map +1 -0
  40. package/dist/introspect/introspection.d.cts +25 -0
  41. package/dist/introspect/introspection.d.cts.map +1 -0
  42. package/dist/introspect/introspection.d.mts +25 -0
  43. package/dist/introspect/introspection.d.mts.map +1 -0
  44. package/dist/introspect/introspection.mjs +146 -0
  45. package/dist/introspect/introspection.mjs.map +1 -0
  46. package/dist/introspect/types.cjs +24 -0
  47. package/dist/introspect/types.cjs.map +1 -0
  48. package/dist/introspect/types.d.cts +113 -0
  49. package/dist/introspect/types.d.cts.map +1 -0
  50. package/dist/introspect/types.d.mts +113 -0
  51. package/dist/introspect/types.d.mts.map +1 -0
  52. package/dist/introspect/types.mjs +23 -0
  53. package/dist/introspect/types.mjs.map +1 -0
  54. package/dist/kysely/pagination.cjs +1 -0
  55. package/dist/kysely/pagination.cjs.map +1 -1
  56. package/dist/kysely/pagination.d.cts +2 -2
  57. package/dist/kysely/pagination.d.cts.map +1 -1
  58. package/dist/kysely/pagination.d.mts +2 -2
  59. package/dist/kysely/pagination.d.mts.map +1 -1
  60. package/dist/kysely/pagination.mjs +2 -2
  61. package/dist/kysely/pagination.mjs.map +1 -1
  62. package/dist/objection/pagination.cjs +1 -0
  63. package/dist/objection/pagination.cjs.map +1 -1
  64. package/dist/objection/pagination.d.cts +2 -2
  65. package/dist/objection/pagination.d.cts.map +1 -1
  66. package/dist/objection/pagination.d.mts +2 -2
  67. package/dist/objection/pagination.d.mts.map +1 -1
  68. package/dist/objection/pagination.mjs +2 -2
  69. package/dist/objection/pagination.mjs.map +1 -1
  70. package/dist/pagination-CrU89125.d.cts +42 -0
  71. package/dist/pagination-CrU89125.d.cts.map +1 -0
  72. package/dist/pagination-CrU89125.d.mts +42 -0
  73. package/dist/pagination-CrU89125.d.mts.map +1 -0
  74. package/dist/pagination.cjs +17 -1
  75. package/dist/pagination.cjs.map +1 -1
  76. package/dist/pagination.d.cts +2 -30
  77. package/dist/pagination.d.mts +2 -30
  78. package/dist/pagination.mjs +17 -2
  79. package/dist/pagination.mjs.map +1 -1
  80. package/dist/pg/roles.cjs +42 -4
  81. package/dist/pg/roles.cjs.map +1 -1
  82. package/dist/pg/roles.d.cts +19 -0
  83. package/dist/pg/roles.d.cts.map +1 -1
  84. package/dist/pg/roles.d.mts +19 -0
  85. package/dist/pg/roles.d.mts.map +1 -1
  86. package/dist/pg/roles.mjs +42 -4
  87. package/dist/pg/roles.mjs.map +1 -1
  88. package/package.json +13 -2
  89. package/dist/pagination.d.cts.map +0 -1
  90. package/dist/pagination.d.mts.map +0 -1
@@ -1,30 +1,2 @@
1
- //#region src/pagination.d.ts
2
- /**
3
- * Sort direction for cursor-based pagination.
4
- */
5
- export declare enum Direction {
6
- Asc = "asc",
7
- Desc = "desc"
8
- }
9
- /**
10
- * Result of a paginated query.
11
- */
12
- export interface PaginationResult<TItem> {
13
- items: TItem[];
14
- pagination: {
15
- total: number;
16
- hasMore: boolean;
17
- cursor?: string;
18
- };
19
- }
20
- /**
21
- * Encode a cursor value for safe URL transmission.
22
- * Supports various types: string, number, Date, etc.
23
- */
24
- export declare function encodeCursor(value: unknown): string;
25
- /**
26
- * Decode a cursor string back to its original value.
27
- */
28
- export declare function decodeCursor(cursor: string): unknown;
29
- //#endregion
30
- //# sourceMappingURL=pagination.d.cts.map
1
+ import { a as encodeCursor, i as decodeCursor, n as InvalidCursor, r as PaginationResult, t as Direction } from "./pagination-CrU89125.cjs";
2
+ export { Direction, InvalidCursor, PaginationResult, decodeCursor, encodeCursor };
@@ -1,30 +1,2 @@
1
- //#region src/pagination.d.ts
2
- /**
3
- * Sort direction for cursor-based pagination.
4
- */
5
- export declare enum Direction {
6
- Asc = "asc",
7
- Desc = "desc"
8
- }
9
- /**
10
- * Result of a paginated query.
11
- */
12
- export interface PaginationResult<TItem> {
13
- items: TItem[];
14
- pagination: {
15
- total: number;
16
- hasMore: boolean;
17
- cursor?: string;
18
- };
19
- }
20
- /**
21
- * Encode a cursor value for safe URL transmission.
22
- * Supports various types: string, number, Date, etc.
23
- */
24
- export declare function encodeCursor(value: unknown): string;
25
- /**
26
- * Decode a cursor string back to its original value.
27
- */
28
- export declare function decodeCursor(cursor: string): unknown;
29
- //#endregion
30
- //# sourceMappingURL=pagination.d.mts.map
1
+ import { a as encodeCursor, i as decodeCursor, n as InvalidCursor, r as PaginationResult, t as Direction } from "./pagination-CrU89125.mjs";
2
+ export { Direction, InvalidCursor, PaginationResult, decodeCursor, encodeCursor };
@@ -28,10 +28,25 @@ function decodeCursor(cursor) {
28
28
  if (payload.t === "date") return new Date(payload.v);
29
29
  return payload.v;
30
30
  } catch {
31
- throw new Error("Invalid cursor format");
31
+ throw new InvalidCursor(cursor);
32
32
  }
33
33
  }
34
+ /**
35
+ * A cursor that did not come from `encodeCursor`.
36
+ *
37
+ * Usually a cursor from a different page size or sort that was edited by hand,
38
+ * or truncated on its way through a URL. The fix is the same either way: start
39
+ * again from the first page.
40
+ */
41
+ var InvalidCursor = class extends Error {
42
+ cursor;
43
+ constructor(cursor) {
44
+ super("This cursor was not produced by encodeCursor. Drop it and request the first page again.");
45
+ this.cursor = cursor;
46
+ this.name = "InvalidCursor";
47
+ }
48
+ };
34
49
  //#endregion
35
- export { Direction, decodeCursor, encodeCursor };
50
+ export { Direction, InvalidCursor, decodeCursor, encodeCursor };
36
51
 
37
52
  //# sourceMappingURL=pagination.mjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"pagination.mjs","names":[],"sources":["../src/pagination.ts"],"sourcesContent":["/**\n * Sort direction for cursor-based pagination.\n */\nexport enum Direction {\n\tAsc = 'asc',\n\tDesc = 'desc',\n}\n\n/**\n * Result of a paginated query.\n */\nexport interface PaginationResult<TItem> {\n\titems: TItem[];\n\tpagination: {\n\t\ttotal: number;\n\t\thasMore: boolean;\n\t\tcursor?: string;\n\t};\n}\n\n/**\n * Encode a cursor value for safe URL transmission.\n * Supports various types: string, number, Date, etc.\n */\nexport function encodeCursor(value: unknown): string {\n\tconst payload = {\n\t\tv: value instanceof Date ? value.toISOString() : value,\n\t\tt: value instanceof Date ? 'date' : typeof value,\n\t};\n\treturn Buffer.from(JSON.stringify(payload)).toString('base64url');\n}\n\n/**\n * Decode a cursor string back to its original value.\n */\nexport function decodeCursor(cursor: string): unknown {\n\ttry {\n\t\tconst json = Buffer.from(cursor, 'base64url').toString('utf-8');\n\t\tconst payload = JSON.parse(json);\n\n\t\tif (payload.t === 'date') {\n\t\t\treturn new Date(payload.v);\n\t\t}\n\n\t\treturn payload.v;\n\t} catch {\n\t\tthrow new Error('Invalid cursor format');\n\t}\n}\n"],"mappings":";;;;AAGA,IAAY,YAAL,yBAAA,WAAA;CACN,UAAA,SAAA;CACA,UAAA,UAAA;;AACD,EAAA,CAAA,CAAA;;;;;AAkBA,SAAgB,aAAa,OAAwB;CACpD,MAAM,UAAU;EACf,GAAG,iBAAiB,OAAO,MAAM,YAAY,IAAI;EACjD,GAAG,iBAAiB,OAAO,SAAS,OAAO;CAC5C;CACA,OAAO,OAAO,KAAK,KAAK,UAAU,OAAO,CAAC,CAAC,CAAC,SAAS,WAAW;AACjE;;;;AAKA,SAAgB,aAAa,QAAyB;CACrD,IAAI;EACH,MAAM,OAAO,OAAO,KAAK,QAAQ,WAAW,CAAC,CAAC,SAAS,OAAO;EAC9D,MAAM,UAAU,KAAK,MAAM,IAAI;EAE/B,IAAI,QAAQ,MAAM,QACjB,OAAO,IAAI,KAAK,QAAQ,CAAC;EAG1B,OAAO,QAAQ;CAChB,QAAQ;EACP,MAAM,IAAI,MAAM,uBAAuB;CACxC;AACD"}
1
+ {"version":3,"file":"pagination.mjs","names":[],"sources":["../src/pagination.ts"],"sourcesContent":["/**\n * Sort direction for cursor-based pagination.\n */\nexport enum Direction {\n\tAsc = 'asc',\n\tDesc = 'desc',\n}\n\n/**\n * Result of a paginated query.\n */\nexport interface PaginationResult<TItem> {\n\titems: TItem[];\n\tpagination: {\n\t\ttotal: number;\n\t\thasMore: boolean;\n\t\tcursor?: string;\n\t};\n}\n\n/**\n * Encode a cursor value for safe URL transmission.\n * Supports various types: string, number, Date, etc.\n */\nexport function encodeCursor(value: unknown): string {\n\tconst payload = {\n\t\tv: value instanceof Date ? value.toISOString() : value,\n\t\tt: value instanceof Date ? 'date' : typeof value,\n\t};\n\treturn Buffer.from(JSON.stringify(payload)).toString('base64url');\n}\n\n/**\n * Decode a cursor string back to its original value.\n */\nexport function decodeCursor(cursor: string): unknown {\n\ttry {\n\t\tconst json = Buffer.from(cursor, 'base64url').toString('utf-8');\n\t\tconst payload = JSON.parse(json);\n\n\t\tif (payload.t === 'date') {\n\t\t\treturn new Date(payload.v);\n\t\t}\n\n\t\treturn payload.v;\n\t} catch {\n\t\tthrow new InvalidCursor(cursor);\n\t}\n}\n\n/**\n * A cursor that did not come from `encodeCursor`.\n *\n * Usually a cursor from a different page size or sort that was edited by hand,\n * or truncated on its way through a URL. The fix is the same either way: start\n * again from the first page.\n */\nexport class InvalidCursor extends Error {\n\tconstructor(readonly cursor: string) {\n\t\tsuper(\n\t\t\t'This cursor was not produced by encodeCursor. Drop it and request the first page again.',\n\t\t);\n\t\tthis.name = 'InvalidCursor';\n\t}\n}\n"],"mappings":";;;;AAGA,IAAY,YAAL,yBAAA,WAAA;CACN,UAAA,SAAA;CACA,UAAA,UAAA;;AACD,EAAA,CAAA,CAAA;;;;;AAkBA,SAAgB,aAAa,OAAwB;CACpD,MAAM,UAAU;EACf,GAAG,iBAAiB,OAAO,MAAM,YAAY,IAAI;EACjD,GAAG,iBAAiB,OAAO,SAAS,OAAO;CAC5C;CACA,OAAO,OAAO,KAAK,KAAK,UAAU,OAAO,CAAC,CAAC,CAAC,SAAS,WAAW;AACjE;;;;AAKA,SAAgB,aAAa,QAAyB;CACrD,IAAI;EACH,MAAM,OAAO,OAAO,KAAK,QAAQ,WAAW,CAAC,CAAC,SAAS,OAAO;EAC9D,MAAM,UAAU,KAAK,MAAM,IAAI;EAE/B,IAAI,QAAQ,MAAM,QACjB,OAAO,IAAI,KAAK,QAAQ,CAAC;EAG1B,OAAO,QAAQ;CAChB,QAAQ;EACP,MAAM,IAAI,cAAc,MAAM;CAC/B;AACD;;;;;;;;AASA,IAAa,gBAAb,cAAmC,MAAM;CACnB;CAArB,YAAY,QAAyB;EACpC,MACC,yFACD;EAHoB,KAAA,SAAA;EAIpB,KAAK,OAAO;CACb;AACD"}
package/dist/pg/roles.cjs CHANGED
@@ -8,18 +8,21 @@ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
8
8
  * leaving a half-granted role that looks complete.
9
9
  */
10
10
  function roleStatements(spec) {
11
- const { runtime, owner, schema, passwords } = spec;
11
+ const { runtime, owner, schema, passwords, database } = spec;
12
+ const searchPath = searchPathOf(spec);
12
13
  return [
13
14
  {
14
15
  describe: `role ${owner}`,
15
16
  exists: roleExists(owner),
16
17
  sql: `CREATE ROLE ${ident(owner)} LOGIN PASSWORD ${literal(passwords.owner)}`
17
18
  },
19
+ passwordStatement(owner, passwords.owner),
18
20
  {
19
21
  describe: `role ${runtime}`,
20
22
  exists: roleExists(runtime),
21
23
  sql: `CREATE ROLE ${ident(runtime)} LOGIN PASSWORD ${literal(passwords.runtime)}`
22
24
  },
25
+ passwordStatement(runtime, passwords.runtime),
23
26
  {
24
27
  describe: `${owner} granted to the connecting role`,
25
28
  sql: `GRANT ${ident(owner)} TO CURRENT_USER`
@@ -36,6 +39,18 @@ function roleStatements(spec) {
36
39
  describe: `${schema} is owned by ${owner}`,
37
40
  sql: `ALTER SCHEMA ${ident(schema)} OWNER TO ${ident(owner)}`
38
41
  },
42
+ ...database ? [{
43
+ describe: `${owner} may create in database ${database}`,
44
+ sql: `GRANT CREATE ON DATABASE ${ident(database)} TO ${ident(owner)}`
45
+ }] : [],
46
+ ...(spec.extensions ?? []).map((extension) => ({
47
+ describe: `extension ${extension}`,
48
+ exists: {
49
+ sql: "SELECT 1 FROM pg_extension WHERE extname = $1",
50
+ values: [extension]
51
+ },
52
+ sql: `CREATE EXTENSION IF NOT EXISTS ${ident(extension)} SCHEMA public`
53
+ })),
39
54
  {
40
55
  describe: `${runtime} may use ${schema}`,
41
56
  sql: `GRANT USAGE ON SCHEMA ${ident(schema)} TO ${ident(runtime)}`
@@ -58,16 +73,31 @@ function roleStatements(spec) {
58
73
  },
59
74
  {
60
75
  describe: `${runtime} resolves names in ${schema}`,
61
- sql: `ALTER ROLE ${ident(runtime)} SET search_path TO ${ident(schema)}`
76
+ sql: `ALTER ROLE ${ident(runtime)} SET search_path TO ${searchPath}`
62
77
  },
63
78
  {
64
79
  describe: `${owner} resolves names in ${schema}`,
65
- sql: `ALTER ROLE ${ident(owner)} SET search_path TO ${ident(schema)}`
80
+ sql: `ALTER ROLE ${ident(owner)} SET search_path TO ${searchPath}`
66
81
  },
67
82
  ...spec.reader ? readerStatements(spec, spec.reader) : []
68
83
  ];
69
84
  }
70
85
  /**
86
+ * The role's password, set to what the caller supplied — every time.
87
+ *
88
+ * `CREATE ROLE` only runs for a role that is not there, so on its own a role
89
+ * keeps the password it was born with, and an app handed a different one is
90
+ * locked out. Setting it on every apply is what lets the supplied password be
91
+ * the truth: rotated, or re-derived, the role follows. Idempotent, so it needs
92
+ * no check.
93
+ */
94
+ function passwordStatement(role, password) {
95
+ return {
96
+ describe: `${role} has its current password`,
97
+ sql: `ALTER ROLE ${ident(role)} WITH LOGIN PASSWORD ${literal(password)}`
98
+ };
99
+ }
100
+ /**
71
101
  * A role that may read and nothing else.
72
102
  *
73
103
  * `SELECT` and no more, on what exists and on what the owner creates later. No
@@ -85,6 +115,7 @@ function readerStatements(spec, reader) {
85
115
  exists: roleExists(reader),
86
116
  sql: `CREATE ROLE ${ident(reader)} LOGIN PASSWORD ${literal(password)}`
87
117
  },
118
+ passwordStatement(reader, password),
88
119
  {
89
120
  describe: `${reader} may use ${schema}`,
90
121
  sql: `GRANT USAGE ON SCHEMA ${ident(schema)} TO ${ident(reader)}`
@@ -99,10 +130,17 @@ function readerStatements(spec, reader) {
99
130
  },
100
131
  {
101
132
  describe: `${reader} resolves names in ${schema}`,
102
- sql: `ALTER ROLE ${ident(reader)} SET search_path TO ${ident(schema)}`
133
+ sql: `ALTER ROLE ${ident(reader)} SET search_path TO ${searchPathOf(spec)}`
103
134
  }
104
135
  ];
105
136
  }
137
+ /**
138
+ * The `search_path` every role of a tenant is pinned to: its schema, and
139
+ * `public` behind it where the database's extensions were created.
140
+ */
141
+ function searchPathOf(spec) {
142
+ return spec.extensions?.length ? `${ident(spec.schema)}, public` : ident(spec.schema);
143
+ }
106
144
  /** A reader role was named and no password was supplied for it. */
107
145
  var ReaderNeedsPassword = class extends Error {
108
146
  role;
@@ -1 +1 @@
1
- {"version":3,"file":"roles.cjs","names":[],"sources":["../../src/pg/roles.ts"],"sourcesContent":["/**\n * The role DDL — an owner, a runtime role, and the grants between them.\n *\n * Generation only. The same statements have to run in-process from `gkm dev`\n * and from a provisioner inside a VPC at deploy, and one implementation is what\n * stops \"works locally, fails deployed\" — so this composes SQL and applies\n * none of it.\n *\n * **What the split is for.** A handler connects as the runtime role, which can\n * read and write rows and cannot create, alter, or drop anything. A migrator\n * connects as the owner, which can. That is a security property rather than a\n * convention — a compromised handler cannot `DROP TABLE` because its role holds\n * no such grant — and it is only a property at all once these statements have\n * actually run. Until then a schema is a namespace, not a boundary.\n *\n * **Passwords are an input.** This module invents no secrets: a caller derives\n * or fetches them and passes them in, because where a password comes from is\n * the one part that legitimately differs between a laptop and a deploy.\n */\n\n/** The roles and schema one tenant needs. */\nexport interface RoleSpec {\n\t/** The role a handler connects as. Reads and writes rows; owns nothing. */\n\truntime: string;\n\t/** The role a migrator connects as. Owns the schema and everything in it. */\n\towner: string;\n\t/**\n\t * A third role that may only read, for a reader endpoint.\n\t *\n\t * Optional because it is only worth creating where something points at it.\n\t * Read-only is enforced *here*, by the grants, rather than by which endpoint\n\t * a URL happens to name — which is what makes falling back to the writer's\n\t * endpoint safe when a cluster has no replica.\n\t */\n\treader?: string;\n\t/** The schema they operate in, pinned on every role's `search_path`. */\n\tschema: string;\n\t/** Supplied, never generated here. */\n\tpasswords: { runtime: string; owner: string; reader?: string };\n}\n\n/** One statement, and how to tell whether it still needs running. */\nexport interface RoleStatement {\n\t/** What it does, for the line a reconciler prints. */\n\tdescribe: string;\n\t/**\n\t * Answers \"does this already exist\", parameterised.\n\t *\n\t * Absent means the statement is idempotent in itself and is run every time —\n\t * a `GRANT` re-granting what is already granted is a no-op, and checking\n\t * would cost a round trip to learn nothing.\n\t */\n\texists?: { sql: string; values: unknown[] };\n\tsql: string;\n}\n\n/**\n * Everything one tenant's roles need, in dependency order.\n *\n * The owner before the schema it owns, the schema before the grants on it. A\n * caller applies them in sequence and may stop at the first failure without\n * leaving a half-granted role that looks complete.\n */\nexport function roleStatements(spec: RoleSpec): RoleStatement[] {\n\tconst { runtime, owner, schema, passwords } = spec;\n\n\treturn [\n\t\t{\n\t\t\tdescribe: `role ${owner}`,\n\t\t\texists: roleExists(owner),\n\t\t\tsql: `CREATE ROLE ${ident(owner)} LOGIN PASSWORD ${literal(passwords.owner)}`,\n\t\t},\n\t\t{\n\t\t\tdescribe: `role ${runtime}`,\n\t\t\texists: roleExists(runtime),\n\t\t\tsql: `CREATE ROLE ${ident(runtime)} LOGIN PASSWORD ${literal(passwords.runtime)}`,\n\t\t},\n\t\t{\n\t\t\t// Postgres will not let you create an object owned by a role you are\n\t\t\t// not a member of, and an RDS master is not a superuser — so\n\t\t\t// `CREATE SCHEMA … AUTHORIZATION` fails with \"must be able to SET\n\t\t\t// ROLE\" unless the connected role is granted membership first.\n\t\t\t//\n\t\t\t// Granting is idempotent and needs no check. It also leaves the master\n\t\t\t// able to administer the schema, which is what you want from the one\n\t\t\t// credential that exists before any role does.\n\t\t\tdescribe: `${owner} granted to the connecting role`,\n\t\t\tsql: `GRANT ${ident(owner)} TO CURRENT_USER`,\n\t\t},\n\t\t{\n\t\t\tdescribe: `schema ${schema}`,\n\t\t\texists: {\n\t\t\t\tsql: 'SELECT 1 FROM information_schema.schemata WHERE schema_name = $1',\n\t\t\t\tvalues: [schema],\n\t\t\t},\n\t\t\t// Owned by the owner from the moment it exists. Creating it as the\n\t\t\t// cluster master and granting afterwards leaves a window where the\n\t\t\t// master owns objects the owner is supposed to.\n\t\t\tsql: `CREATE SCHEMA ${ident(schema)} AUTHORIZATION ${ident(owner)}`,\n\t\t},\n\t\t{\n\t\t\t// And the schema that was already there, which the guard above skips.\n\t\t\t// A schema created before its roles existed is owned by whoever\n\t\t\t// created it — the cluster master — and nothing below fixes that, so\n\t\t\t// the owner cannot create in the schema it is supposed to own. The\n\t\t\t// failure is `no schema has been selected to create in`, which names\n\t\t\t// neither the schema nor the owner and reads like a missing\n\t\t\t// `search_path`.\n\t\t\t//\n\t\t\t// Idempotent when the owner is already right, so it costs a statement\n\t\t\t// rather than a check.\n\t\t\tdescribe: `${schema} is owned by ${owner}`,\n\t\t\tsql: `ALTER SCHEMA ${ident(schema)} OWNER TO ${ident(owner)}`,\n\t\t},\n\t\t{\n\t\t\tdescribe: `${runtime} may use ${schema}`,\n\t\t\tsql: `GRANT USAGE ON SCHEMA ${ident(schema)} TO ${ident(runtime)}`,\n\t\t},\n\t\t{\n\t\t\t// The important half: *future* tables. A grant on what exists today\n\t\t\t// covers nothing the next migration creates, which is how a runtime\n\t\t\t// role silently loses access one deploy later.\n\t\t\tdescribe: `${runtime} may read and write future tables in ${schema}`,\n\t\t\tsql:\n\t\t\t\t`ALTER DEFAULT PRIVILEGES FOR ROLE ${ident(owner)} IN SCHEMA ${ident(schema)} ` +\n\t\t\t\t`GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO ${ident(runtime)}`,\n\t\t},\n\t\t{\n\t\t\tdescribe: `${runtime} may use future sequences in ${schema}`,\n\t\t\tsql:\n\t\t\t\t`ALTER DEFAULT PRIVILEGES FOR ROLE ${ident(owner)} IN SCHEMA ${ident(schema)} ` +\n\t\t\t\t`GRANT USAGE, SELECT ON SEQUENCES TO ${ident(runtime)}`,\n\t\t},\n\t\t{\n\t\t\t// And the tables that are already there, for a schema that existed\n\t\t\t// before its roles did.\n\t\t\tdescribe: `${runtime} may read and write existing tables in ${schema}`,\n\t\t\tsql:\n\t\t\t\t`GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA ${ident(schema)} ` +\n\t\t\t\t`TO ${ident(runtime)}`,\n\t\t},\n\t\t{\n\t\t\tdescribe: `${runtime} may use existing sequences in ${schema}`,\n\t\t\tsql: `GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA ${ident(schema)} TO ${ident(runtime)}`,\n\t\t},\n\t\t{\n\t\t\t// Pinned on the role rather than carried in every URL. A connection\n\t\t\t// string that has to remember `search_path` is one that eventually\n\t\t\t// forgets, and the forgetting looks like an empty database.\n\t\t\tdescribe: `${runtime} resolves names in ${schema}`,\n\t\t\tsql: `ALTER ROLE ${ident(runtime)} SET search_path TO ${ident(schema)}`,\n\t\t},\n\t\t{\n\t\t\tdescribe: `${owner} resolves names in ${schema}`,\n\t\t\tsql: `ALTER ROLE ${ident(owner)} SET search_path TO ${ident(schema)}`,\n\t\t},\n\t\t...(spec.reader ? readerStatements(spec, spec.reader) : []),\n\t];\n}\n\n/**\n * A role that may read and nothing else.\n *\n * `SELECT` and no more, on what exists and on what the owner creates later. No\n * `INSERT`, no `UPDATE`, no `DELETE`, and no sequence `USAGE` — a reader that\n * could take a sequence value could not write the row it was for, so granting\n * it would be surface with no use.\n */\nfunction readerStatements(spec: RoleSpec, reader: string): RoleStatement[] {\n\tconst { owner, schema, passwords } = spec;\n\tconst password = passwords.reader;\n\n\tif (!password) throw new ReaderNeedsPassword(reader);\n\n\treturn [\n\t\t{\n\t\t\tdescribe: `role ${reader}`,\n\t\t\texists: roleExists(reader),\n\t\t\tsql: `CREATE ROLE ${ident(reader)} LOGIN PASSWORD ${literal(password)}`,\n\t\t},\n\t\t{\n\t\t\tdescribe: `${reader} may use ${schema}`,\n\t\t\tsql: `GRANT USAGE ON SCHEMA ${ident(schema)} TO ${ident(reader)}`,\n\t\t},\n\t\t{\n\t\t\tdescribe: `${reader} may read future tables in ${schema}`,\n\t\t\tsql:\n\t\t\t\t`ALTER DEFAULT PRIVILEGES FOR ROLE ${ident(owner)} IN SCHEMA ${ident(schema)} ` +\n\t\t\t\t`GRANT SELECT ON TABLES TO ${ident(reader)}`,\n\t\t},\n\t\t{\n\t\t\tdescribe: `${reader} may read existing tables in ${schema}`,\n\t\t\tsql: `GRANT SELECT ON ALL TABLES IN SCHEMA ${ident(schema)} TO ${ident(reader)}`,\n\t\t},\n\t\t{\n\t\t\tdescribe: `${reader} resolves names in ${schema}`,\n\t\t\tsql: `ALTER ROLE ${ident(reader)} SET search_path TO ${ident(schema)}`,\n\t\t},\n\t];\n}\n\n/** A reader role was named and no password was supplied for it. */\nexport class ReaderNeedsPassword extends Error {\n\tconstructor(readonly role: string) {\n\t\tsuper(\n\t\t\t`'${role}' is a reader role and no password was supplied for it. ` +\n\t\t\t\t`This module composes DDL and invents no secrets: pass ` +\n\t\t\t\t`passwords.reader alongside the role name.`,\n\t\t);\n\t\tthis.name = 'ReaderNeedsPassword';\n\t}\n}\n\n/** The read-only role's name for a given runtime role. */\nexport function readerRole(runtime: string): string {\n\treturn `${runtime}_reader`;\n}\n\n/** The owner role's name for a given runtime role. */\nexport function ownerRole(runtime: string): string {\n\treturn `${runtime}_owner`;\n}\n\nfunction roleExists(role: string): { sql: string; values: unknown[] } {\n\treturn { sql: 'SELECT 1 FROM pg_roles WHERE rolname = $1', values: [role] };\n}\n\n/**\n * Quote an identifier for use in DDL.\n *\n * Names reaching here are already canonical, so this is defence rather than\n * sanitisation — but DDL cannot be parameterised, and an unquoted identifier is\n * the one place a name could ever be more than a name.\n */\nfunction ident(name: string): string {\n\treturn `\"${name.replace(/\"/g, '\"\"')}\"`;\n}\n\n/**\n * Quote a string literal.\n *\n * A password cannot be a bind parameter in `CREATE ROLE`, which is the one\n * place in this module where a value rather than a name is interpolated — so it\n * is escaped rather than trusted, whatever the caller believes it derived.\n */\nfunction literal(value: string): string {\n\treturn `'${value.replace(/'/g, \"''\")}'`;\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"roles.cjs","names":[],"sources":["../../src/pg/roles.ts"],"sourcesContent":["/**\n * The role DDL — an owner, a runtime role, and the grants between them.\n *\n * Generation only. The same statements have to run in-process from `gkm dev`\n * and from a provisioner inside a VPC at deploy, and one implementation is what\n * stops \"works locally, fails deployed\" — so this composes SQL and applies\n * none of it.\n *\n * **What the split is for.** A handler connects as the runtime role, which can\n * read and write rows and cannot create, alter, or drop anything. A migrator\n * connects as the owner, which can. That is a security property rather than a\n * convention — a compromised handler cannot `DROP TABLE` because its role holds\n * no such grant — and it is only a property at all once these statements have\n * actually run. Until then a schema is a namespace, not a boundary.\n *\n * **Passwords are an input.** This module invents no secrets: a caller derives\n * or fetches them and passes them in, because where a password comes from is\n * the one part that legitimately differs between a laptop and a deploy.\n */\n\n/** The roles and schema one tenant needs. */\nexport interface RoleSpec {\n\t/** The role a handler connects as. Reads and writes rows; owns nothing. */\n\truntime: string;\n\t/** The role a migrator connects as. Owns the schema and everything in it. */\n\towner: string;\n\t/**\n\t * A third role that may only read, for a reader endpoint.\n\t *\n\t * Optional because it is only worth creating where something points at it.\n\t * Read-only is enforced *here*, by the grants, rather than by which endpoint\n\t * a URL happens to name — which is what makes falling back to the writer's\n\t * endpoint safe when a cluster has no replica.\n\t */\n\treader?: string;\n\t/** The schema they operate in, pinned on every role's `search_path`. */\n\tschema: string;\n\t/**\n\t * The database itself, when these are a database construct's roles rather\n\t * than a schema tenant's.\n\t *\n\t * Its owner may then create in the database — which is what a migration\n\t * needs to create a trusted extension (`citext`, `pg_trgm`): Postgres asks\n\t * for `CREATE` on the database for that, not on the schema. A tenant's owner\n\t * is not given it, so it stays confined to its own schema.\n\t */\n\tdatabase?: string;\n\t/**\n\t * Extensions the database declared that its owner may not create — PostGIS\n\t * is not trusted, so only the connecting master can.\n\t *\n\t * Created in `public`, where every tutorial and tool expects them, and\n\t * `public` joins the roles' `search_path` behind the schema so `geometry`\n\t * resolves unqualified. Tables still land in the schema: it comes first.\n\t */\n\textensions?: readonly string[];\n\t/** Supplied, never generated here. */\n\tpasswords: { runtime: string; owner: string; reader?: string };\n}\n\n/** One statement, and how to tell whether it still needs running. */\nexport interface RoleStatement {\n\t/** What it does, for the line a reconciler prints. */\n\tdescribe: string;\n\t/**\n\t * Answers \"does this already exist\", parameterised.\n\t *\n\t * Absent means the statement is idempotent in itself and is run every time —\n\t * a `GRANT` re-granting what is already granted is a no-op, and checking\n\t * would cost a round trip to learn nothing.\n\t */\n\texists?: { sql: string; values: unknown[] };\n\tsql: string;\n}\n\n/**\n * Everything one tenant's roles need, in dependency order.\n *\n * The owner before the schema it owns, the schema before the grants on it. A\n * caller applies them in sequence and may stop at the first failure without\n * leaving a half-granted role that looks complete.\n */\nexport function roleStatements(spec: RoleSpec): RoleStatement[] {\n\tconst { runtime, owner, schema, passwords, database } = spec;\n\tconst searchPath = searchPathOf(spec);\n\n\treturn [\n\t\t{\n\t\t\tdescribe: `role ${owner}`,\n\t\t\texists: roleExists(owner),\n\t\t\tsql: `CREATE ROLE ${ident(owner)} LOGIN PASSWORD ${literal(passwords.owner)}`,\n\t\t},\n\t\tpasswordStatement(owner, passwords.owner),\n\t\t{\n\t\t\tdescribe: `role ${runtime}`,\n\t\t\texists: roleExists(runtime),\n\t\t\tsql: `CREATE ROLE ${ident(runtime)} LOGIN PASSWORD ${literal(passwords.runtime)}`,\n\t\t},\n\t\tpasswordStatement(runtime, passwords.runtime),\n\t\t{\n\t\t\t// Postgres will not let you create an object owned by a role you are\n\t\t\t// not a member of, and an RDS master is not a superuser — so\n\t\t\t// `CREATE SCHEMA … AUTHORIZATION` fails with \"must be able to SET\n\t\t\t// ROLE\" unless the connected role is granted membership first.\n\t\t\t//\n\t\t\t// Granting is idempotent and needs no check. It also leaves the master\n\t\t\t// able to administer the schema, which is what you want from the one\n\t\t\t// credential that exists before any role does.\n\t\t\tdescribe: `${owner} granted to the connecting role`,\n\t\t\tsql: `GRANT ${ident(owner)} TO CURRENT_USER`,\n\t\t},\n\t\t{\n\t\t\tdescribe: `schema ${schema}`,\n\t\t\texists: {\n\t\t\t\tsql: 'SELECT 1 FROM information_schema.schemata WHERE schema_name = $1',\n\t\t\t\tvalues: [schema],\n\t\t\t},\n\t\t\t// Owned by the owner from the moment it exists. Creating it as the\n\t\t\t// cluster master and granting afterwards leaves a window where the\n\t\t\t// master owns objects the owner is supposed to.\n\t\t\tsql: `CREATE SCHEMA ${ident(schema)} AUTHORIZATION ${ident(owner)}`,\n\t\t},\n\t\t{\n\t\t\t// And the schema that was already there, which the guard above skips.\n\t\t\t// A schema created before its roles existed is owned by whoever\n\t\t\t// created it — the cluster master — and nothing below fixes that, so\n\t\t\t// the owner cannot create in the schema it is supposed to own. The\n\t\t\t// failure is `no schema has been selected to create in`, which names\n\t\t\t// neither the schema nor the owner and reads like a missing\n\t\t\t// `search_path`.\n\t\t\t//\n\t\t\t// Idempotent when the owner is already right, so it costs a statement\n\t\t\t// rather than a check.\n\t\t\tdescribe: `${schema} is owned by ${owner}`,\n\t\t\tsql: `ALTER SCHEMA ${ident(schema)} OWNER TO ${ident(owner)}`,\n\t\t},\n\t\t...(database\n\t\t\t? [\n\t\t\t\t\t{\n\t\t\t\t\t\t// The migrator of a database, not of a tenant inside one: it may\n\t\t\t\t\t\t// create trusted extensions, which Postgres grants on the\n\t\t\t\t\t\t// database rather than the schema. Idempotent, so no check.\n\t\t\t\t\t\tdescribe: `${owner} may create in database ${database}`,\n\t\t\t\t\t\tsql: `GRANT CREATE ON DATABASE ${ident(database)} TO ${ident(owner)}`,\n\t\t\t\t\t},\n\t\t\t\t]\n\t\t\t: []),\n\t\t...(spec.extensions ?? []).map((extension) => ({\n\t\t\tdescribe: `extension ${extension}`,\n\t\t\texists: {\n\t\t\t\tsql: 'SELECT 1 FROM pg_extension WHERE extname = $1',\n\t\t\t\tvalues: [extension],\n\t\t\t},\n\t\t\tsql: `CREATE EXTENSION IF NOT EXISTS ${ident(extension)} SCHEMA public`,\n\t\t})),\n\t\t{\n\t\t\tdescribe: `${runtime} may use ${schema}`,\n\t\t\tsql: `GRANT USAGE ON SCHEMA ${ident(schema)} TO ${ident(runtime)}`,\n\t\t},\n\t\t{\n\t\t\t// The important half: *future* tables. A grant on what exists today\n\t\t\t// covers nothing the next migration creates, which is how a runtime\n\t\t\t// role silently loses access one deploy later.\n\t\t\tdescribe: `${runtime} may read and write future tables in ${schema}`,\n\t\t\tsql:\n\t\t\t\t`ALTER DEFAULT PRIVILEGES FOR ROLE ${ident(owner)} IN SCHEMA ${ident(schema)} ` +\n\t\t\t\t`GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO ${ident(runtime)}`,\n\t\t},\n\t\t{\n\t\t\tdescribe: `${runtime} may use future sequences in ${schema}`,\n\t\t\tsql:\n\t\t\t\t`ALTER DEFAULT PRIVILEGES FOR ROLE ${ident(owner)} IN SCHEMA ${ident(schema)} ` +\n\t\t\t\t`GRANT USAGE, SELECT ON SEQUENCES TO ${ident(runtime)}`,\n\t\t},\n\t\t{\n\t\t\t// And the tables that are already there, for a schema that existed\n\t\t\t// before its roles did.\n\t\t\tdescribe: `${runtime} may read and write existing tables in ${schema}`,\n\t\t\tsql:\n\t\t\t\t`GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA ${ident(schema)} ` +\n\t\t\t\t`TO ${ident(runtime)}`,\n\t\t},\n\t\t{\n\t\t\tdescribe: `${runtime} may use existing sequences in ${schema}`,\n\t\t\tsql: `GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA ${ident(schema)} TO ${ident(runtime)}`,\n\t\t},\n\t\t{\n\t\t\t// Pinned on the role rather than carried in every URL. A connection\n\t\t\t// string that has to remember `search_path` is one that eventually\n\t\t\t// forgets, and the forgetting looks like an empty database.\n\t\t\tdescribe: `${runtime} resolves names in ${schema}`,\n\t\t\tsql: `ALTER ROLE ${ident(runtime)} SET search_path TO ${searchPath}`,\n\t\t},\n\t\t{\n\t\t\tdescribe: `${owner} resolves names in ${schema}`,\n\t\t\tsql: `ALTER ROLE ${ident(owner)} SET search_path TO ${searchPath}`,\n\t\t},\n\t\t...(spec.reader ? readerStatements(spec, spec.reader) : []),\n\t];\n}\n\n/**\n * The role's password, set to what the caller supplied — every time.\n *\n * `CREATE ROLE` only runs for a role that is not there, so on its own a role\n * keeps the password it was born with, and an app handed a different one is\n * locked out. Setting it on every apply is what lets the supplied password be\n * the truth: rotated, or re-derived, the role follows. Idempotent, so it needs\n * no check.\n */\nfunction passwordStatement(role: string, password: string): RoleStatement {\n\treturn {\n\t\tdescribe: `${role} has its current password`,\n\t\tsql: `ALTER ROLE ${ident(role)} WITH LOGIN PASSWORD ${literal(password)}`,\n\t};\n}\n\n/**\n * A role that may read and nothing else.\n *\n * `SELECT` and no more, on what exists and on what the owner creates later. No\n * `INSERT`, no `UPDATE`, no `DELETE`, and no sequence `USAGE` — a reader that\n * could take a sequence value could not write the row it was for, so granting\n * it would be surface with no use.\n */\nfunction readerStatements(spec: RoleSpec, reader: string): RoleStatement[] {\n\tconst { owner, schema, passwords } = spec;\n\tconst password = passwords.reader;\n\n\tif (!password) throw new ReaderNeedsPassword(reader);\n\n\treturn [\n\t\t{\n\t\t\tdescribe: `role ${reader}`,\n\t\t\texists: roleExists(reader),\n\t\t\tsql: `CREATE ROLE ${ident(reader)} LOGIN PASSWORD ${literal(password)}`,\n\t\t},\n\t\tpasswordStatement(reader, password),\n\t\t{\n\t\t\tdescribe: `${reader} may use ${schema}`,\n\t\t\tsql: `GRANT USAGE ON SCHEMA ${ident(schema)} TO ${ident(reader)}`,\n\t\t},\n\t\t{\n\t\t\tdescribe: `${reader} may read future tables in ${schema}`,\n\t\t\tsql:\n\t\t\t\t`ALTER DEFAULT PRIVILEGES FOR ROLE ${ident(owner)} IN SCHEMA ${ident(schema)} ` +\n\t\t\t\t`GRANT SELECT ON TABLES TO ${ident(reader)}`,\n\t\t},\n\t\t{\n\t\t\tdescribe: `${reader} may read existing tables in ${schema}`,\n\t\t\tsql: `GRANT SELECT ON ALL TABLES IN SCHEMA ${ident(schema)} TO ${ident(reader)}`,\n\t\t},\n\t\t{\n\t\t\tdescribe: `${reader} resolves names in ${schema}`,\n\t\t\tsql: `ALTER ROLE ${ident(reader)} SET search_path TO ${searchPathOf(spec)}`,\n\t\t},\n\t];\n}\n\n/**\n * The `search_path` every role of a tenant is pinned to: its schema, and\n * `public` behind it where the database's extensions were created.\n */\nfunction searchPathOf(spec: RoleSpec): string {\n\treturn spec.extensions?.length\n\t\t? `${ident(spec.schema)}, public`\n\t\t: ident(spec.schema);\n}\n\n/** A reader role was named and no password was supplied for it. */\nexport class ReaderNeedsPassword extends Error {\n\tconstructor(readonly role: string) {\n\t\tsuper(\n\t\t\t`'${role}' is a reader role and no password was supplied for it. ` +\n\t\t\t\t`This module composes DDL and invents no secrets: pass ` +\n\t\t\t\t`passwords.reader alongside the role name.`,\n\t\t);\n\t\tthis.name = 'ReaderNeedsPassword';\n\t}\n}\n\n/** The read-only role's name for a given runtime role. */\nexport function readerRole(runtime: string): string {\n\treturn `${runtime}_reader`;\n}\n\n/** The owner role's name for a given runtime role. */\nexport function ownerRole(runtime: string): string {\n\treturn `${runtime}_owner`;\n}\n\nfunction roleExists(role: string): { sql: string; values: unknown[] } {\n\treturn { sql: 'SELECT 1 FROM pg_roles WHERE rolname = $1', values: [role] };\n}\n\n/**\n * Quote an identifier for use in DDL.\n *\n * Names reaching here are already canonical, so this is defence rather than\n * sanitisation — but DDL cannot be parameterised, and an unquoted identifier is\n * the one place a name could ever be more than a name.\n */\nfunction ident(name: string): string {\n\treturn `\"${name.replace(/\"/g, '\"\"')}\"`;\n}\n\n/**\n * Quote a string literal.\n *\n * A password cannot be a bind parameter in `CREATE ROLE`, which is the one\n * place in this module where a value rather than a name is interpolated — so it\n * is escaped rather than trusted, whatever the caller believes it derived.\n */\nfunction literal(value: string): string {\n\treturn `'${value.replace(/'/g, \"''\")}'`;\n}\n"],"mappings":"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"}
@@ -35,6 +35,25 @@ export interface RoleSpec {
35
35
  reader?: string;
36
36
  /** The schema they operate in, pinned on every role's `search_path`. */
37
37
  schema: string;
38
+ /**
39
+ * The database itself, when these are a database construct's roles rather
40
+ * than a schema tenant's.
41
+ *
42
+ * Its owner may then create in the database — which is what a migration
43
+ * needs to create a trusted extension (`citext`, `pg_trgm`): Postgres asks
44
+ * for `CREATE` on the database for that, not on the schema. A tenant's owner
45
+ * is not given it, so it stays confined to its own schema.
46
+ */
47
+ database?: string;
48
+ /**
49
+ * Extensions the database declared that its owner may not create — PostGIS
50
+ * is not trusted, so only the connecting master can.
51
+ *
52
+ * Created in `public`, where every tutorial and tool expects them, and
53
+ * `public` joins the roles' `search_path` behind the schema so `geometry`
54
+ * resolves unqualified. Tables still land in the schema: it comes first.
55
+ */
56
+ extensions?: readonly string[];
38
57
  /** Supplied, never generated here. */
39
58
  passwords: {
40
59
  runtime: string;
@@ -1 +1 @@
1
- {"version":3,"file":"roles.d.cts","names":[],"sources":["../../src/pg/roles.ts"],"mappings":";;;;;;;;;;;;;;;;;;;;;iBAqBiB;;EAEhB;;EAEA;;;;;;;;;EASA;;EAEA;;EAEA;IAAa;IAAiB;IAAe;;;;iBAI7B;;EAEhB;;;;;;;;EAQA;IAAW;IAAa;;EACxB;;;;;;;;;wBAUe,eAAe,MAAM,WAAW;;qBA2InC,4BAA4B;WACnB;EAArB,YAAqB;;;wBAWN,WAAW;;wBAKX,UAAU"}
1
+ {"version":3,"file":"roles.d.cts","names":[],"sources":["../../src/pg/roles.ts"],"mappings":";;;;;;;;;;;;;;;;;;;;;iBAqBiB;;EAEhB;;EAEA;;;;;;;;;EASA;;EAEA;;;;;;;;;;EAUA;;;;;;;;;EASA;;EAEA;IAAa;IAAiB;IAAe;;;;iBAI7B;;EAEhB;;;;;;;;EAQA;IAAW;IAAa;;EACxB;;;;;;;;;wBAUe,eAAe,MAAM,WAAW;;qBA4LnC,4BAA4B;WACnB;EAArB,YAAqB;;;wBAWN,WAAW;;wBAKX,UAAU"}
@@ -35,6 +35,25 @@ export interface RoleSpec {
35
35
  reader?: string;
36
36
  /** The schema they operate in, pinned on every role's `search_path`. */
37
37
  schema: string;
38
+ /**
39
+ * The database itself, when these are a database construct's roles rather
40
+ * than a schema tenant's.
41
+ *
42
+ * Its owner may then create in the database — which is what a migration
43
+ * needs to create a trusted extension (`citext`, `pg_trgm`): Postgres asks
44
+ * for `CREATE` on the database for that, not on the schema. A tenant's owner
45
+ * is not given it, so it stays confined to its own schema.
46
+ */
47
+ database?: string;
48
+ /**
49
+ * Extensions the database declared that its owner may not create — PostGIS
50
+ * is not trusted, so only the connecting master can.
51
+ *
52
+ * Created in `public`, where every tutorial and tool expects them, and
53
+ * `public` joins the roles' `search_path` behind the schema so `geometry`
54
+ * resolves unqualified. Tables still land in the schema: it comes first.
55
+ */
56
+ extensions?: readonly string[];
38
57
  /** Supplied, never generated here. */
39
58
  passwords: {
40
59
  runtime: string;
@@ -1 +1 @@
1
- {"version":3,"file":"roles.d.mts","names":[],"sources":["../../src/pg/roles.ts"],"mappings":";;;;;;;;;;;;;;;;;;;;;iBAqBiB;;EAEhB;;EAEA;;;;;;;;;EASA;;EAEA;;EAEA;IAAa;IAAiB;IAAe;;;;iBAI7B;;EAEhB;;;;;;;;EAQA;IAAW;IAAa;;EACxB;;;;;;;;;wBAUe,eAAe,MAAM,WAAW;;qBA2InC,4BAA4B;WACnB;EAArB,YAAqB;;;wBAWN,WAAW;;wBAKX,UAAU"}
1
+ {"version":3,"file":"roles.d.mts","names":[],"sources":["../../src/pg/roles.ts"],"mappings":";;;;;;;;;;;;;;;;;;;;;iBAqBiB;;EAEhB;;EAEA;;;;;;;;;EASA;;EAEA;;;;;;;;;;EAUA;;;;;;;;;EASA;;EAEA;IAAa;IAAiB;IAAe;;;;iBAI7B;;EAEhB;;;;;;;;EAQA;IAAW;IAAa;;EACxB;;;;;;;;;wBAUe,eAAe,MAAM,WAAW;;qBA4LnC,4BAA4B;WACnB;EAArB,YAAqB;;;wBAWN,WAAW;;wBAKX,UAAU"}
package/dist/pg/roles.mjs CHANGED
@@ -7,18 +7,21 @@
7
7
  * leaving a half-granted role that looks complete.
8
8
  */
9
9
  function roleStatements(spec) {
10
- const { runtime, owner, schema, passwords } = spec;
10
+ const { runtime, owner, schema, passwords, database } = spec;
11
+ const searchPath = searchPathOf(spec);
11
12
  return [
12
13
  {
13
14
  describe: `role ${owner}`,
14
15
  exists: roleExists(owner),
15
16
  sql: `CREATE ROLE ${ident(owner)} LOGIN PASSWORD ${literal(passwords.owner)}`
16
17
  },
18
+ passwordStatement(owner, passwords.owner),
17
19
  {
18
20
  describe: `role ${runtime}`,
19
21
  exists: roleExists(runtime),
20
22
  sql: `CREATE ROLE ${ident(runtime)} LOGIN PASSWORD ${literal(passwords.runtime)}`
21
23
  },
24
+ passwordStatement(runtime, passwords.runtime),
22
25
  {
23
26
  describe: `${owner} granted to the connecting role`,
24
27
  sql: `GRANT ${ident(owner)} TO CURRENT_USER`
@@ -35,6 +38,18 @@ function roleStatements(spec) {
35
38
  describe: `${schema} is owned by ${owner}`,
36
39
  sql: `ALTER SCHEMA ${ident(schema)} OWNER TO ${ident(owner)}`
37
40
  },
41
+ ...database ? [{
42
+ describe: `${owner} may create in database ${database}`,
43
+ sql: `GRANT CREATE ON DATABASE ${ident(database)} TO ${ident(owner)}`
44
+ }] : [],
45
+ ...(spec.extensions ?? []).map((extension) => ({
46
+ describe: `extension ${extension}`,
47
+ exists: {
48
+ sql: "SELECT 1 FROM pg_extension WHERE extname = $1",
49
+ values: [extension]
50
+ },
51
+ sql: `CREATE EXTENSION IF NOT EXISTS ${ident(extension)} SCHEMA public`
52
+ })),
38
53
  {
39
54
  describe: `${runtime} may use ${schema}`,
40
55
  sql: `GRANT USAGE ON SCHEMA ${ident(schema)} TO ${ident(runtime)}`
@@ -57,16 +72,31 @@ function roleStatements(spec) {
57
72
  },
58
73
  {
59
74
  describe: `${runtime} resolves names in ${schema}`,
60
- sql: `ALTER ROLE ${ident(runtime)} SET search_path TO ${ident(schema)}`
75
+ sql: `ALTER ROLE ${ident(runtime)} SET search_path TO ${searchPath}`
61
76
  },
62
77
  {
63
78
  describe: `${owner} resolves names in ${schema}`,
64
- sql: `ALTER ROLE ${ident(owner)} SET search_path TO ${ident(schema)}`
79
+ sql: `ALTER ROLE ${ident(owner)} SET search_path TO ${searchPath}`
65
80
  },
66
81
  ...spec.reader ? readerStatements(spec, spec.reader) : []
67
82
  ];
68
83
  }
69
84
  /**
85
+ * The role's password, set to what the caller supplied — every time.
86
+ *
87
+ * `CREATE ROLE` only runs for a role that is not there, so on its own a role
88
+ * keeps the password it was born with, and an app handed a different one is
89
+ * locked out. Setting it on every apply is what lets the supplied password be
90
+ * the truth: rotated, or re-derived, the role follows. Idempotent, so it needs
91
+ * no check.
92
+ */
93
+ function passwordStatement(role, password) {
94
+ return {
95
+ describe: `${role} has its current password`,
96
+ sql: `ALTER ROLE ${ident(role)} WITH LOGIN PASSWORD ${literal(password)}`
97
+ };
98
+ }
99
+ /**
70
100
  * A role that may read and nothing else.
71
101
  *
72
102
  * `SELECT` and no more, on what exists and on what the owner creates later. No
@@ -84,6 +114,7 @@ function readerStatements(spec, reader) {
84
114
  exists: roleExists(reader),
85
115
  sql: `CREATE ROLE ${ident(reader)} LOGIN PASSWORD ${literal(password)}`
86
116
  },
117
+ passwordStatement(reader, password),
87
118
  {
88
119
  describe: `${reader} may use ${schema}`,
89
120
  sql: `GRANT USAGE ON SCHEMA ${ident(schema)} TO ${ident(reader)}`
@@ -98,10 +129,17 @@ function readerStatements(spec, reader) {
98
129
  },
99
130
  {
100
131
  describe: `${reader} resolves names in ${schema}`,
101
- sql: `ALTER ROLE ${ident(reader)} SET search_path TO ${ident(schema)}`
132
+ sql: `ALTER ROLE ${ident(reader)} SET search_path TO ${searchPathOf(spec)}`
102
133
  }
103
134
  ];
104
135
  }
136
+ /**
137
+ * The `search_path` every role of a tenant is pinned to: its schema, and
138
+ * `public` behind it where the database's extensions were created.
139
+ */
140
+ function searchPathOf(spec) {
141
+ return spec.extensions?.length ? `${ident(spec.schema)}, public` : ident(spec.schema);
142
+ }
105
143
  /** A reader role was named and no password was supplied for it. */
106
144
  var ReaderNeedsPassword = class extends Error {
107
145
  role;
@@ -1 +1 @@
1
- {"version":3,"file":"roles.mjs","names":[],"sources":["../../src/pg/roles.ts"],"sourcesContent":["/**\n * The role DDL — an owner, a runtime role, and the grants between them.\n *\n * Generation only. The same statements have to run in-process from `gkm dev`\n * and from a provisioner inside a VPC at deploy, and one implementation is what\n * stops \"works locally, fails deployed\" — so this composes SQL and applies\n * none of it.\n *\n * **What the split is for.** A handler connects as the runtime role, which can\n * read and write rows and cannot create, alter, or drop anything. A migrator\n * connects as the owner, which can. That is a security property rather than a\n * convention — a compromised handler cannot `DROP TABLE` because its role holds\n * no such grant — and it is only a property at all once these statements have\n * actually run. Until then a schema is a namespace, not a boundary.\n *\n * **Passwords are an input.** This module invents no secrets: a caller derives\n * or fetches them and passes them in, because where a password comes from is\n * the one part that legitimately differs between a laptop and a deploy.\n */\n\n/** The roles and schema one tenant needs. */\nexport interface RoleSpec {\n\t/** The role a handler connects as. Reads and writes rows; owns nothing. */\n\truntime: string;\n\t/** The role a migrator connects as. Owns the schema and everything in it. */\n\towner: string;\n\t/**\n\t * A third role that may only read, for a reader endpoint.\n\t *\n\t * Optional because it is only worth creating where something points at it.\n\t * Read-only is enforced *here*, by the grants, rather than by which endpoint\n\t * a URL happens to name — which is what makes falling back to the writer's\n\t * endpoint safe when a cluster has no replica.\n\t */\n\treader?: string;\n\t/** The schema they operate in, pinned on every role's `search_path`. */\n\tschema: string;\n\t/** Supplied, never generated here. */\n\tpasswords: { runtime: string; owner: string; reader?: string };\n}\n\n/** One statement, and how to tell whether it still needs running. */\nexport interface RoleStatement {\n\t/** What it does, for the line a reconciler prints. */\n\tdescribe: string;\n\t/**\n\t * Answers \"does this already exist\", parameterised.\n\t *\n\t * Absent means the statement is idempotent in itself and is run every time —\n\t * a `GRANT` re-granting what is already granted is a no-op, and checking\n\t * would cost a round trip to learn nothing.\n\t */\n\texists?: { sql: string; values: unknown[] };\n\tsql: string;\n}\n\n/**\n * Everything one tenant's roles need, in dependency order.\n *\n * The owner before the schema it owns, the schema before the grants on it. A\n * caller applies them in sequence and may stop at the first failure without\n * leaving a half-granted role that looks complete.\n */\nexport function roleStatements(spec: RoleSpec): RoleStatement[] {\n\tconst { runtime, owner, schema, passwords } = spec;\n\n\treturn [\n\t\t{\n\t\t\tdescribe: `role ${owner}`,\n\t\t\texists: roleExists(owner),\n\t\t\tsql: `CREATE ROLE ${ident(owner)} LOGIN PASSWORD ${literal(passwords.owner)}`,\n\t\t},\n\t\t{\n\t\t\tdescribe: `role ${runtime}`,\n\t\t\texists: roleExists(runtime),\n\t\t\tsql: `CREATE ROLE ${ident(runtime)} LOGIN PASSWORD ${literal(passwords.runtime)}`,\n\t\t},\n\t\t{\n\t\t\t// Postgres will not let you create an object owned by a role you are\n\t\t\t// not a member of, and an RDS master is not a superuser — so\n\t\t\t// `CREATE SCHEMA … AUTHORIZATION` fails with \"must be able to SET\n\t\t\t// ROLE\" unless the connected role is granted membership first.\n\t\t\t//\n\t\t\t// Granting is idempotent and needs no check. It also leaves the master\n\t\t\t// able to administer the schema, which is what you want from the one\n\t\t\t// credential that exists before any role does.\n\t\t\tdescribe: `${owner} granted to the connecting role`,\n\t\t\tsql: `GRANT ${ident(owner)} TO CURRENT_USER`,\n\t\t},\n\t\t{\n\t\t\tdescribe: `schema ${schema}`,\n\t\t\texists: {\n\t\t\t\tsql: 'SELECT 1 FROM information_schema.schemata WHERE schema_name = $1',\n\t\t\t\tvalues: [schema],\n\t\t\t},\n\t\t\t// Owned by the owner from the moment it exists. Creating it as the\n\t\t\t// cluster master and granting afterwards leaves a window where the\n\t\t\t// master owns objects the owner is supposed to.\n\t\t\tsql: `CREATE SCHEMA ${ident(schema)} AUTHORIZATION ${ident(owner)}`,\n\t\t},\n\t\t{\n\t\t\t// And the schema that was already there, which the guard above skips.\n\t\t\t// A schema created before its roles existed is owned by whoever\n\t\t\t// created it — the cluster master — and nothing below fixes that, so\n\t\t\t// the owner cannot create in the schema it is supposed to own. The\n\t\t\t// failure is `no schema has been selected to create in`, which names\n\t\t\t// neither the schema nor the owner and reads like a missing\n\t\t\t// `search_path`.\n\t\t\t//\n\t\t\t// Idempotent when the owner is already right, so it costs a statement\n\t\t\t// rather than a check.\n\t\t\tdescribe: `${schema} is owned by ${owner}`,\n\t\t\tsql: `ALTER SCHEMA ${ident(schema)} OWNER TO ${ident(owner)}`,\n\t\t},\n\t\t{\n\t\t\tdescribe: `${runtime} may use ${schema}`,\n\t\t\tsql: `GRANT USAGE ON SCHEMA ${ident(schema)} TO ${ident(runtime)}`,\n\t\t},\n\t\t{\n\t\t\t// The important half: *future* tables. A grant on what exists today\n\t\t\t// covers nothing the next migration creates, which is how a runtime\n\t\t\t// role silently loses access one deploy later.\n\t\t\tdescribe: `${runtime} may read and write future tables in ${schema}`,\n\t\t\tsql:\n\t\t\t\t`ALTER DEFAULT PRIVILEGES FOR ROLE ${ident(owner)} IN SCHEMA ${ident(schema)} ` +\n\t\t\t\t`GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO ${ident(runtime)}`,\n\t\t},\n\t\t{\n\t\t\tdescribe: `${runtime} may use future sequences in ${schema}`,\n\t\t\tsql:\n\t\t\t\t`ALTER DEFAULT PRIVILEGES FOR ROLE ${ident(owner)} IN SCHEMA ${ident(schema)} ` +\n\t\t\t\t`GRANT USAGE, SELECT ON SEQUENCES TO ${ident(runtime)}`,\n\t\t},\n\t\t{\n\t\t\t// And the tables that are already there, for a schema that existed\n\t\t\t// before its roles did.\n\t\t\tdescribe: `${runtime} may read and write existing tables in ${schema}`,\n\t\t\tsql:\n\t\t\t\t`GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA ${ident(schema)} ` +\n\t\t\t\t`TO ${ident(runtime)}`,\n\t\t},\n\t\t{\n\t\t\tdescribe: `${runtime} may use existing sequences in ${schema}`,\n\t\t\tsql: `GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA ${ident(schema)} TO ${ident(runtime)}`,\n\t\t},\n\t\t{\n\t\t\t// Pinned on the role rather than carried in every URL. A connection\n\t\t\t// string that has to remember `search_path` is one that eventually\n\t\t\t// forgets, and the forgetting looks like an empty database.\n\t\t\tdescribe: `${runtime} resolves names in ${schema}`,\n\t\t\tsql: `ALTER ROLE ${ident(runtime)} SET search_path TO ${ident(schema)}`,\n\t\t},\n\t\t{\n\t\t\tdescribe: `${owner} resolves names in ${schema}`,\n\t\t\tsql: `ALTER ROLE ${ident(owner)} SET search_path TO ${ident(schema)}`,\n\t\t},\n\t\t...(spec.reader ? readerStatements(spec, spec.reader) : []),\n\t];\n}\n\n/**\n * A role that may read and nothing else.\n *\n * `SELECT` and no more, on what exists and on what the owner creates later. No\n * `INSERT`, no `UPDATE`, no `DELETE`, and no sequence `USAGE` — a reader that\n * could take a sequence value could not write the row it was for, so granting\n * it would be surface with no use.\n */\nfunction readerStatements(spec: RoleSpec, reader: string): RoleStatement[] {\n\tconst { owner, schema, passwords } = spec;\n\tconst password = passwords.reader;\n\n\tif (!password) throw new ReaderNeedsPassword(reader);\n\n\treturn [\n\t\t{\n\t\t\tdescribe: `role ${reader}`,\n\t\t\texists: roleExists(reader),\n\t\t\tsql: `CREATE ROLE ${ident(reader)} LOGIN PASSWORD ${literal(password)}`,\n\t\t},\n\t\t{\n\t\t\tdescribe: `${reader} may use ${schema}`,\n\t\t\tsql: `GRANT USAGE ON SCHEMA ${ident(schema)} TO ${ident(reader)}`,\n\t\t},\n\t\t{\n\t\t\tdescribe: `${reader} may read future tables in ${schema}`,\n\t\t\tsql:\n\t\t\t\t`ALTER DEFAULT PRIVILEGES FOR ROLE ${ident(owner)} IN SCHEMA ${ident(schema)} ` +\n\t\t\t\t`GRANT SELECT ON TABLES TO ${ident(reader)}`,\n\t\t},\n\t\t{\n\t\t\tdescribe: `${reader} may read existing tables in ${schema}`,\n\t\t\tsql: `GRANT SELECT ON ALL TABLES IN SCHEMA ${ident(schema)} TO ${ident(reader)}`,\n\t\t},\n\t\t{\n\t\t\tdescribe: `${reader} resolves names in ${schema}`,\n\t\t\tsql: `ALTER ROLE ${ident(reader)} SET search_path TO ${ident(schema)}`,\n\t\t},\n\t];\n}\n\n/** A reader role was named and no password was supplied for it. */\nexport class ReaderNeedsPassword extends Error {\n\tconstructor(readonly role: string) {\n\t\tsuper(\n\t\t\t`'${role}' is a reader role and no password was supplied for it. ` +\n\t\t\t\t`This module composes DDL and invents no secrets: pass ` +\n\t\t\t\t`passwords.reader alongside the role name.`,\n\t\t);\n\t\tthis.name = 'ReaderNeedsPassword';\n\t}\n}\n\n/** The read-only role's name for a given runtime role. */\nexport function readerRole(runtime: string): string {\n\treturn `${runtime}_reader`;\n}\n\n/** The owner role's name for a given runtime role. */\nexport function ownerRole(runtime: string): string {\n\treturn `${runtime}_owner`;\n}\n\nfunction roleExists(role: string): { sql: string; values: unknown[] } {\n\treturn { sql: 'SELECT 1 FROM pg_roles WHERE rolname = $1', values: [role] };\n}\n\n/**\n * Quote an identifier for use in DDL.\n *\n * Names reaching here are already canonical, so this is defence rather than\n * sanitisation — but DDL cannot be parameterised, and an unquoted identifier is\n * the one place a name could ever be more than a name.\n */\nfunction ident(name: string): string {\n\treturn `\"${name.replace(/\"/g, '\"\"')}\"`;\n}\n\n/**\n * Quote a string literal.\n *\n * A password cannot be a bind parameter in `CREATE ROLE`, which is the one\n * place in this module where a value rather than a name is interpolated — so it\n * is escaped rather than trusted, whatever the caller believes it derived.\n */\nfunction literal(value: string): string {\n\treturn `'${value.replace(/'/g, \"''\")}'`;\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"roles.mjs","names":[],"sources":["../../src/pg/roles.ts"],"sourcesContent":["/**\n * The role DDL — an owner, a runtime role, and the grants between them.\n *\n * Generation only. The same statements have to run in-process from `gkm dev`\n * and from a provisioner inside a VPC at deploy, and one implementation is what\n * stops \"works locally, fails deployed\" — so this composes SQL and applies\n * none of it.\n *\n * **What the split is for.** A handler connects as the runtime role, which can\n * read and write rows and cannot create, alter, or drop anything. A migrator\n * connects as the owner, which can. That is a security property rather than a\n * convention — a compromised handler cannot `DROP TABLE` because its role holds\n * no such grant — and it is only a property at all once these statements have\n * actually run. Until then a schema is a namespace, not a boundary.\n *\n * **Passwords are an input.** This module invents no secrets: a caller derives\n * or fetches them and passes them in, because where a password comes from is\n * the one part that legitimately differs between a laptop and a deploy.\n */\n\n/** The roles and schema one tenant needs. */\nexport interface RoleSpec {\n\t/** The role a handler connects as. Reads and writes rows; owns nothing. */\n\truntime: string;\n\t/** The role a migrator connects as. Owns the schema and everything in it. */\n\towner: string;\n\t/**\n\t * A third role that may only read, for a reader endpoint.\n\t *\n\t * Optional because it is only worth creating where something points at it.\n\t * Read-only is enforced *here*, by the grants, rather than by which endpoint\n\t * a URL happens to name — which is what makes falling back to the writer's\n\t * endpoint safe when a cluster has no replica.\n\t */\n\treader?: string;\n\t/** The schema they operate in, pinned on every role's `search_path`. */\n\tschema: string;\n\t/**\n\t * The database itself, when these are a database construct's roles rather\n\t * than a schema tenant's.\n\t *\n\t * Its owner may then create in the database — which is what a migration\n\t * needs to create a trusted extension (`citext`, `pg_trgm`): Postgres asks\n\t * for `CREATE` on the database for that, not on the schema. A tenant's owner\n\t * is not given it, so it stays confined to its own schema.\n\t */\n\tdatabase?: string;\n\t/**\n\t * Extensions the database declared that its owner may not create — PostGIS\n\t * is not trusted, so only the connecting master can.\n\t *\n\t * Created in `public`, where every tutorial and tool expects them, and\n\t * `public` joins the roles' `search_path` behind the schema so `geometry`\n\t * resolves unqualified. Tables still land in the schema: it comes first.\n\t */\n\textensions?: readonly string[];\n\t/** Supplied, never generated here. */\n\tpasswords: { runtime: string; owner: string; reader?: string };\n}\n\n/** One statement, and how to tell whether it still needs running. */\nexport interface RoleStatement {\n\t/** What it does, for the line a reconciler prints. */\n\tdescribe: string;\n\t/**\n\t * Answers \"does this already exist\", parameterised.\n\t *\n\t * Absent means the statement is idempotent in itself and is run every time —\n\t * a `GRANT` re-granting what is already granted is a no-op, and checking\n\t * would cost a round trip to learn nothing.\n\t */\n\texists?: { sql: string; values: unknown[] };\n\tsql: string;\n}\n\n/**\n * Everything one tenant's roles need, in dependency order.\n *\n * The owner before the schema it owns, the schema before the grants on it. A\n * caller applies them in sequence and may stop at the first failure without\n * leaving a half-granted role that looks complete.\n */\nexport function roleStatements(spec: RoleSpec): RoleStatement[] {\n\tconst { runtime, owner, schema, passwords, database } = spec;\n\tconst searchPath = searchPathOf(spec);\n\n\treturn [\n\t\t{\n\t\t\tdescribe: `role ${owner}`,\n\t\t\texists: roleExists(owner),\n\t\t\tsql: `CREATE ROLE ${ident(owner)} LOGIN PASSWORD ${literal(passwords.owner)}`,\n\t\t},\n\t\tpasswordStatement(owner, passwords.owner),\n\t\t{\n\t\t\tdescribe: `role ${runtime}`,\n\t\t\texists: roleExists(runtime),\n\t\t\tsql: `CREATE ROLE ${ident(runtime)} LOGIN PASSWORD ${literal(passwords.runtime)}`,\n\t\t},\n\t\tpasswordStatement(runtime, passwords.runtime),\n\t\t{\n\t\t\t// Postgres will not let you create an object owned by a role you are\n\t\t\t// not a member of, and an RDS master is not a superuser — so\n\t\t\t// `CREATE SCHEMA … AUTHORIZATION` fails with \"must be able to SET\n\t\t\t// ROLE\" unless the connected role is granted membership first.\n\t\t\t//\n\t\t\t// Granting is idempotent and needs no check. It also leaves the master\n\t\t\t// able to administer the schema, which is what you want from the one\n\t\t\t// credential that exists before any role does.\n\t\t\tdescribe: `${owner} granted to the connecting role`,\n\t\t\tsql: `GRANT ${ident(owner)} TO CURRENT_USER`,\n\t\t},\n\t\t{\n\t\t\tdescribe: `schema ${schema}`,\n\t\t\texists: {\n\t\t\t\tsql: 'SELECT 1 FROM information_schema.schemata WHERE schema_name = $1',\n\t\t\t\tvalues: [schema],\n\t\t\t},\n\t\t\t// Owned by the owner from the moment it exists. Creating it as the\n\t\t\t// cluster master and granting afterwards leaves a window where the\n\t\t\t// master owns objects the owner is supposed to.\n\t\t\tsql: `CREATE SCHEMA ${ident(schema)} AUTHORIZATION ${ident(owner)}`,\n\t\t},\n\t\t{\n\t\t\t// And the schema that was already there, which the guard above skips.\n\t\t\t// A schema created before its roles existed is owned by whoever\n\t\t\t// created it — the cluster master — and nothing below fixes that, so\n\t\t\t// the owner cannot create in the schema it is supposed to own. The\n\t\t\t// failure is `no schema has been selected to create in`, which names\n\t\t\t// neither the schema nor the owner and reads like a missing\n\t\t\t// `search_path`.\n\t\t\t//\n\t\t\t// Idempotent when the owner is already right, so it costs a statement\n\t\t\t// rather than a check.\n\t\t\tdescribe: `${schema} is owned by ${owner}`,\n\t\t\tsql: `ALTER SCHEMA ${ident(schema)} OWNER TO ${ident(owner)}`,\n\t\t},\n\t\t...(database\n\t\t\t? [\n\t\t\t\t\t{\n\t\t\t\t\t\t// The migrator of a database, not of a tenant inside one: it may\n\t\t\t\t\t\t// create trusted extensions, which Postgres grants on the\n\t\t\t\t\t\t// database rather than the schema. Idempotent, so no check.\n\t\t\t\t\t\tdescribe: `${owner} may create in database ${database}`,\n\t\t\t\t\t\tsql: `GRANT CREATE ON DATABASE ${ident(database)} TO ${ident(owner)}`,\n\t\t\t\t\t},\n\t\t\t\t]\n\t\t\t: []),\n\t\t...(spec.extensions ?? []).map((extension) => ({\n\t\t\tdescribe: `extension ${extension}`,\n\t\t\texists: {\n\t\t\t\tsql: 'SELECT 1 FROM pg_extension WHERE extname = $1',\n\t\t\t\tvalues: [extension],\n\t\t\t},\n\t\t\tsql: `CREATE EXTENSION IF NOT EXISTS ${ident(extension)} SCHEMA public`,\n\t\t})),\n\t\t{\n\t\t\tdescribe: `${runtime} may use ${schema}`,\n\t\t\tsql: `GRANT USAGE ON SCHEMA ${ident(schema)} TO ${ident(runtime)}`,\n\t\t},\n\t\t{\n\t\t\t// The important half: *future* tables. A grant on what exists today\n\t\t\t// covers nothing the next migration creates, which is how a runtime\n\t\t\t// role silently loses access one deploy later.\n\t\t\tdescribe: `${runtime} may read and write future tables in ${schema}`,\n\t\t\tsql:\n\t\t\t\t`ALTER DEFAULT PRIVILEGES FOR ROLE ${ident(owner)} IN SCHEMA ${ident(schema)} ` +\n\t\t\t\t`GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO ${ident(runtime)}`,\n\t\t},\n\t\t{\n\t\t\tdescribe: `${runtime} may use future sequences in ${schema}`,\n\t\t\tsql:\n\t\t\t\t`ALTER DEFAULT PRIVILEGES FOR ROLE ${ident(owner)} IN SCHEMA ${ident(schema)} ` +\n\t\t\t\t`GRANT USAGE, SELECT ON SEQUENCES TO ${ident(runtime)}`,\n\t\t},\n\t\t{\n\t\t\t// And the tables that are already there, for a schema that existed\n\t\t\t// before its roles did.\n\t\t\tdescribe: `${runtime} may read and write existing tables in ${schema}`,\n\t\t\tsql:\n\t\t\t\t`GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA ${ident(schema)} ` +\n\t\t\t\t`TO ${ident(runtime)}`,\n\t\t},\n\t\t{\n\t\t\tdescribe: `${runtime} may use existing sequences in ${schema}`,\n\t\t\tsql: `GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA ${ident(schema)} TO ${ident(runtime)}`,\n\t\t},\n\t\t{\n\t\t\t// Pinned on the role rather than carried in every URL. A connection\n\t\t\t// string that has to remember `search_path` is one that eventually\n\t\t\t// forgets, and the forgetting looks like an empty database.\n\t\t\tdescribe: `${runtime} resolves names in ${schema}`,\n\t\t\tsql: `ALTER ROLE ${ident(runtime)} SET search_path TO ${searchPath}`,\n\t\t},\n\t\t{\n\t\t\tdescribe: `${owner} resolves names in ${schema}`,\n\t\t\tsql: `ALTER ROLE ${ident(owner)} SET search_path TO ${searchPath}`,\n\t\t},\n\t\t...(spec.reader ? readerStatements(spec, spec.reader) : []),\n\t];\n}\n\n/**\n * The role's password, set to what the caller supplied — every time.\n *\n * `CREATE ROLE` only runs for a role that is not there, so on its own a role\n * keeps the password it was born with, and an app handed a different one is\n * locked out. Setting it on every apply is what lets the supplied password be\n * the truth: rotated, or re-derived, the role follows. Idempotent, so it needs\n * no check.\n */\nfunction passwordStatement(role: string, password: string): RoleStatement {\n\treturn {\n\t\tdescribe: `${role} has its current password`,\n\t\tsql: `ALTER ROLE ${ident(role)} WITH LOGIN PASSWORD ${literal(password)}`,\n\t};\n}\n\n/**\n * A role that may read and nothing else.\n *\n * `SELECT` and no more, on what exists and on what the owner creates later. No\n * `INSERT`, no `UPDATE`, no `DELETE`, and no sequence `USAGE` — a reader that\n * could take a sequence value could not write the row it was for, so granting\n * it would be surface with no use.\n */\nfunction readerStatements(spec: RoleSpec, reader: string): RoleStatement[] {\n\tconst { owner, schema, passwords } = spec;\n\tconst password = passwords.reader;\n\n\tif (!password) throw new ReaderNeedsPassword(reader);\n\n\treturn [\n\t\t{\n\t\t\tdescribe: `role ${reader}`,\n\t\t\texists: roleExists(reader),\n\t\t\tsql: `CREATE ROLE ${ident(reader)} LOGIN PASSWORD ${literal(password)}`,\n\t\t},\n\t\tpasswordStatement(reader, password),\n\t\t{\n\t\t\tdescribe: `${reader} may use ${schema}`,\n\t\t\tsql: `GRANT USAGE ON SCHEMA ${ident(schema)} TO ${ident(reader)}`,\n\t\t},\n\t\t{\n\t\t\tdescribe: `${reader} may read future tables in ${schema}`,\n\t\t\tsql:\n\t\t\t\t`ALTER DEFAULT PRIVILEGES FOR ROLE ${ident(owner)} IN SCHEMA ${ident(schema)} ` +\n\t\t\t\t`GRANT SELECT ON TABLES TO ${ident(reader)}`,\n\t\t},\n\t\t{\n\t\t\tdescribe: `${reader} may read existing tables in ${schema}`,\n\t\t\tsql: `GRANT SELECT ON ALL TABLES IN SCHEMA ${ident(schema)} TO ${ident(reader)}`,\n\t\t},\n\t\t{\n\t\t\tdescribe: `${reader} resolves names in ${schema}`,\n\t\t\tsql: `ALTER ROLE ${ident(reader)} SET search_path TO ${searchPathOf(spec)}`,\n\t\t},\n\t];\n}\n\n/**\n * The `search_path` every role of a tenant is pinned to: its schema, and\n * `public` behind it where the database's extensions were created.\n */\nfunction searchPathOf(spec: RoleSpec): string {\n\treturn spec.extensions?.length\n\t\t? `${ident(spec.schema)}, public`\n\t\t: ident(spec.schema);\n}\n\n/** A reader role was named and no password was supplied for it. */\nexport class ReaderNeedsPassword extends Error {\n\tconstructor(readonly role: string) {\n\t\tsuper(\n\t\t\t`'${role}' is a reader role and no password was supplied for it. ` +\n\t\t\t\t`This module composes DDL and invents no secrets: pass ` +\n\t\t\t\t`passwords.reader alongside the role name.`,\n\t\t);\n\t\tthis.name = 'ReaderNeedsPassword';\n\t}\n}\n\n/** The read-only role's name for a given runtime role. */\nexport function readerRole(runtime: string): string {\n\treturn `${runtime}_reader`;\n}\n\n/** The owner role's name for a given runtime role. */\nexport function ownerRole(runtime: string): string {\n\treturn `${runtime}_owner`;\n}\n\nfunction roleExists(role: string): { sql: string; values: unknown[] } {\n\treturn { sql: 'SELECT 1 FROM pg_roles WHERE rolname = $1', values: [role] };\n}\n\n/**\n * Quote an identifier for use in DDL.\n *\n * Names reaching here are already canonical, so this is defence rather than\n * sanitisation — but DDL cannot be parameterised, and an unquoted identifier is\n * the one place a name could ever be more than a name.\n */\nfunction ident(name: string): string {\n\treturn `\"${name.replace(/\"/g, '\"\"')}\"`;\n}\n\n/**\n * Quote a string literal.\n *\n * A password cannot be a bind parameter in `CREATE ROLE`, which is the one\n * place in this module where a value rather than a name is interpolated — so it\n * is escaped rather than trusted, whatever the caller believes it derived.\n */\nfunction literal(value: string): string {\n\treturn `'${value.replace(/'/g, \"''\")}'`;\n}\n"],"mappings":";;;;;;;;AAkFA,SAAgB,eAAe,MAAiC;CAC/D,MAAM,EAAE,SAAS,OAAO,QAAQ,WAAW,aAAa;CACxD,MAAM,aAAa,aAAa,IAAI;CAEpC,OAAO;EACN;GACC,UAAU,QAAQ;GAClB,QAAQ,WAAW,KAAK;GACxB,KAAK,eAAe,MAAM,KAAK,EAAE,kBAAkB,QAAQ,UAAU,KAAK;EAC3E;EACA,kBAAkB,OAAO,UAAU,KAAK;EACxC;GACC,UAAU,QAAQ;GAClB,QAAQ,WAAW,OAAO;GAC1B,KAAK,eAAe,MAAM,OAAO,EAAE,kBAAkB,QAAQ,UAAU,OAAO;EAC/E;EACA,kBAAkB,SAAS,UAAU,OAAO;EAC5C;GASC,UAAU,GAAG,MAAM;GACnB,KAAK,SAAS,MAAM,KAAK,EAAE;EAC5B;EACA;GACC,UAAU,UAAU;GACpB,QAAQ;IACP,KAAK;IACL,QAAQ,CAAC,MAAM;GAChB;GAIA,KAAK,iBAAiB,MAAM,MAAM,EAAE,iBAAiB,MAAM,KAAK;EACjE;EACA;GAWC,UAAU,GAAG,OAAO,eAAe;GACnC,KAAK,gBAAgB,MAAM,MAAM,EAAE,YAAY,MAAM,KAAK;EAC3D;EACA,GAAI,WACD,CACA;GAIC,UAAU,GAAG,MAAM,0BAA0B;GAC7C,KAAK,4BAA4B,MAAM,QAAQ,EAAE,MAAM,MAAM,KAAK;EACnE,CACD,IACC,CAAC;EACJ,IAAI,KAAK,cAAc,CAAC,EAAA,CAAG,KAAK,eAAe;GAC9C,UAAU,aAAa;GACvB,QAAQ;IACP,KAAK;IACL,QAAQ,CAAC,SAAS;GACnB;GACA,KAAK,kCAAkC,MAAM,SAAS,EAAE;EACzD,EAAE;EACF;GACC,UAAU,GAAG,QAAQ,WAAW;GAChC,KAAK,yBAAyB,MAAM,MAAM,EAAE,MAAM,MAAM,OAAO;EAChE;EACA;GAIC,UAAU,GAAG,QAAQ,uCAAuC;GAC5D,KACC,qCAAqC,MAAM,KAAK,EAAE,aAAa,MAAM,MAAM,EAAE,qDACxB,MAAM,OAAO;EACpE;EACA;GACC,UAAU,GAAG,QAAQ,+BAA+B;GACpD,KACC,qCAAqC,MAAM,KAAK,EAAE,aAAa,MAAM,MAAM,EAAE,uCACtC,MAAM,OAAO;EACtD;EACA;GAGC,UAAU,GAAG,QAAQ,yCAAyC;GAC9D,KACC,gEAAgE,MAAM,MAAM,EAAE,MACxE,MAAM,OAAO;EACrB;EACA;GACC,UAAU,GAAG,QAAQ,iCAAiC;GACtD,KAAK,kDAAkD,MAAM,MAAM,EAAE,MAAM,MAAM,OAAO;EACzF;EACA;GAIC,UAAU,GAAG,QAAQ,qBAAqB;GAC1C,KAAK,cAAc,MAAM,OAAO,EAAE,sBAAsB;EACzD;EACA;GACC,UAAU,GAAG,MAAM,qBAAqB;GACxC,KAAK,cAAc,MAAM,KAAK,EAAE,sBAAsB;EACvD;EACA,GAAI,KAAK,SAAS,iBAAiB,MAAM,KAAK,MAAM,IAAI,CAAC;CAC1D;AACD;;;;;;;;;;AAWA,SAAS,kBAAkB,MAAc,UAAiC;CACzE,OAAO;EACN,UAAU,GAAG,KAAK;EAClB,KAAK,cAAc,MAAM,IAAI,EAAE,uBAAuB,QAAQ,QAAQ;CACvE;AACD;;;;;;;;;AAUA,SAAS,iBAAiB,MAAgB,QAAiC;CAC1E,MAAM,EAAE,OAAO,QAAQ,cAAc;CACrC,MAAM,WAAW,UAAU;CAE3B,IAAI,CAAC,UAAU,MAAM,IAAI,oBAAoB,MAAM;CAEnD,OAAO;EACN;GACC,UAAU,QAAQ;GAClB,QAAQ,WAAW,MAAM;GACzB,KAAK,eAAe,MAAM,MAAM,EAAE,kBAAkB,QAAQ,QAAQ;EACrE;EACA,kBAAkB,QAAQ,QAAQ;EAClC;GACC,UAAU,GAAG,OAAO,WAAW;GAC/B,KAAK,yBAAyB,MAAM,MAAM,EAAE,MAAM,MAAM,MAAM;EAC/D;EACA;GACC,UAAU,GAAG,OAAO,6BAA6B;GACjD,KACC,qCAAqC,MAAM,KAAK,EAAE,aAAa,MAAM,MAAM,EAAE,6BAChD,MAAM,MAAM;EAC3C;EACA;GACC,UAAU,GAAG,OAAO,+BAA+B;GACnD,KAAK,wCAAwC,MAAM,MAAM,EAAE,MAAM,MAAM,MAAM;EAC9E;EACA;GACC,UAAU,GAAG,OAAO,qBAAqB;GACzC,KAAK,cAAc,MAAM,MAAM,EAAE,sBAAsB,aAAa,IAAI;EACzE;CACD;AACD;;;;;AAMA,SAAS,aAAa,MAAwB;CAC7C,OAAO,KAAK,YAAY,SACrB,GAAG,MAAM,KAAK,MAAM,EAAE,YACtB,MAAM,KAAK,MAAM;AACrB;;AAGA,IAAa,sBAAb,cAAyC,MAAM;CACzB;CAArB,YAAY,MAAuB;EAClC,MACC,IAAI,KAAK,wJAGV;EALoB,KAAA,OAAA;EAMpB,KAAK,OAAO;CACb;AACD;;AAGA,SAAgB,WAAW,SAAyB;CACnD,OAAO,GAAG,QAAQ;AACnB;;AAGA,SAAgB,UAAU,SAAyB;CAClD,OAAO,GAAG,QAAQ;AACnB;AAEA,SAAS,WAAW,MAAkD;CACrE,OAAO;EAAE,KAAK;EAA6C,QAAQ,CAAC,IAAI;CAAE;AAC3E;;;;;;;;AASA,SAAS,MAAM,MAAsB;CACpC,OAAO,IAAI,KAAK,QAAQ,MAAM,MAAI,EAAE;AACrC;;;;;;;;AASA,SAAS,QAAQ,OAAuB;CACvC,OAAO,IAAI,MAAM,QAAQ,MAAM,IAAI,EAAE;AACtC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@geekmidas/db",
3
- "version": "10.0.0-alpha.16",
3
+ "version": "10.0.0-alpha.161",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "exports": {
@@ -83,6 +83,16 @@
83
83
  "types": "./dist/errors.d.cts",
84
84
  "default": "./dist/errors.cjs"
85
85
  }
86
+ },
87
+ "./introspect": {
88
+ "import": {
89
+ "types": "./dist/introspect/index.d.mts",
90
+ "default": "./dist/introspect/index.mjs"
91
+ },
92
+ "require": {
93
+ "types": "./dist/introspect/index.d.cts",
94
+ "default": "./dist/introspect/index.cjs"
95
+ }
86
96
  }
87
97
  },
88
98
  "files": [
@@ -111,6 +121,7 @@
111
121
  "vitest": "~5.0.2"
112
122
  },
113
123
  "scripts": {
114
- "ts": "tsc --noEmit --skipLibCheck src/**/*.ts"
124
+ "ts": "tsc --noEmit --skipLibCheck src/**/*.ts",
125
+ "build": "tsdown --config ../../tsdown.config.ts --filter ."
115
126
  }
116
127
  }
@@ -1 +0,0 @@
1
- {"version":3,"file":"pagination.d.cts","names":[],"sources":["../src/pagination.ts"],"mappings":";;;;oBAGY;EACX;EACA;;;;;iBAMgB,iBAAiB;EACjC,OAAO;EACP;IACC;IACA;IACA;;;;;;;wBAQc,aAAa;;;;wBAWb,aAAa"}
@@ -1 +0,0 @@
1
- {"version":3,"file":"pagination.d.mts","names":[],"sources":["../src/pagination.ts"],"mappings":";;;;oBAGY;EACX;EACA;;;;;iBAMgB,iBAAiB;EACjC,OAAO;EACP;IACC;IACA;IACA;;;;;;;wBAQc,aAAa;;;;wBAWb,aAAa"}