@geekmidas/db 10.0.0-alpha.6 → 10.0.0-alpha.60

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 (138) hide show
  1. package/README.md +17 -0
  2. package/dist/errors.cjs +72 -5
  3. package/dist/errors.cjs.map +1 -0
  4. package/dist/errors.d.cts +4 -6
  5. package/dist/errors.d.cts.map +1 -1
  6. package/dist/errors.d.mts +4 -6
  7. package/dist/errors.d.mts.map +1 -1
  8. package/dist/errors.mjs +68 -2
  9. package/dist/errors.mjs.map +1 -0
  10. package/dist/introspect/DataBrowser.cjs +117 -0
  11. package/dist/introspect/DataBrowser.cjs.map +1 -0
  12. package/dist/introspect/DataBrowser.d.cts +51 -0
  13. package/dist/introspect/DataBrowser.d.cts.map +1 -0
  14. package/dist/introspect/DataBrowser.d.mts +51 -0
  15. package/dist/introspect/DataBrowser.d.mts.map +1 -0
  16. package/dist/introspect/DataBrowser.mjs +116 -0
  17. package/dist/introspect/DataBrowser.mjs.map +1 -0
  18. package/dist/introspect/errors.cjs +48 -0
  19. package/dist/introspect/errors.cjs.map +1 -0
  20. package/dist/introspect/errors.d.cts +27 -0
  21. package/dist/introspect/errors.d.cts.map +1 -0
  22. package/dist/introspect/errors.d.mts +27 -0
  23. package/dist/introspect/errors.d.mts.map +1 -0
  24. package/dist/introspect/errors.mjs +45 -0
  25. package/dist/introspect/errors.mjs.map +1 -0
  26. package/dist/introspect/filtering.cjs +101 -0
  27. package/dist/introspect/filtering.cjs.map +1 -0
  28. package/dist/introspect/filtering.d.cts +29 -0
  29. package/dist/introspect/filtering.d.cts.map +1 -0
  30. package/dist/introspect/filtering.d.mts +29 -0
  31. package/dist/introspect/filtering.d.mts.map +1 -0
  32. package/dist/introspect/filtering.mjs +98 -0
  33. package/dist/introspect/filtering.mjs.map +1 -0
  34. package/dist/introspect/handler.cjs +159 -0
  35. package/dist/introspect/handler.cjs.map +1 -0
  36. package/dist/introspect/handler.d.cts +43 -0
  37. package/dist/introspect/handler.d.cts.map +1 -0
  38. package/dist/introspect/handler.d.mts +43 -0
  39. package/dist/introspect/handler.d.mts.map +1 -0
  40. package/dist/introspect/handler.mjs +158 -0
  41. package/dist/introspect/handler.mjs.map +1 -0
  42. package/dist/introspect/index.cjs +22 -0
  43. package/dist/introspect/index.d.cts +8 -0
  44. package/dist/introspect/index.d.mts +8 -0
  45. package/dist/introspect/index.mjs +8 -0
  46. package/dist/introspect/introspection.cjs +149 -0
  47. package/dist/introspect/introspection.cjs.map +1 -0
  48. package/dist/introspect/introspection.d.cts +25 -0
  49. package/dist/introspect/introspection.d.cts.map +1 -0
  50. package/dist/introspect/introspection.d.mts +25 -0
  51. package/dist/introspect/introspection.d.mts.map +1 -0
  52. package/dist/introspect/introspection.mjs +146 -0
  53. package/dist/introspect/introspection.mjs.map +1 -0
  54. package/dist/introspect/types.cjs +24 -0
  55. package/dist/introspect/types.cjs.map +1 -0
  56. package/dist/introspect/types.d.cts +113 -0
  57. package/dist/introspect/types.d.cts.map +1 -0
  58. package/dist/introspect/types.d.mts +113 -0
  59. package/dist/introspect/types.d.mts.map +1 -0
  60. package/dist/introspect/types.mjs +23 -0
  61. package/dist/introspect/types.mjs.map +1 -0
  62. package/dist/kysely/pagination.cjs +8 -9
  63. package/dist/kysely/pagination.cjs.map +1 -1
  64. package/dist/kysely/pagination.d.cts +4 -14
  65. package/dist/kysely/pagination.d.cts.map +1 -1
  66. package/dist/kysely/pagination.d.mts +4 -14
  67. package/dist/kysely/pagination.d.mts.map +1 -1
  68. package/dist/kysely/pagination.mjs +7 -10
  69. package/dist/kysely/pagination.mjs.map +1 -1
  70. package/dist/kysely.cjs +11 -2
  71. package/dist/kysely.cjs.map +1 -0
  72. package/dist/kysely.d.cts +9 -2
  73. package/dist/kysely.d.cts.map +1 -0
  74. package/dist/kysely.d.mts +9 -2
  75. package/dist/kysely.d.mts.map +1 -0
  76. package/dist/kysely.mjs +10 -2
  77. package/dist/kysely.mjs.map +1 -0
  78. package/dist/objection/pagination.cjs +7 -7
  79. package/dist/objection/pagination.cjs.map +1 -1
  80. package/dist/objection/pagination.d.cts +4 -14
  81. package/dist/objection/pagination.d.cts.map +1 -1
  82. package/dist/objection/pagination.d.mts +4 -14
  83. package/dist/objection/pagination.d.mts.map +1 -1
  84. package/dist/objection/pagination.mjs +6 -8
  85. package/dist/objection/pagination.mjs.map +1 -1
  86. package/dist/{pagination-kv-KWS-7.d.mts → pagination-CrU89125.d.cts} +14 -4
  87. package/dist/pagination-CrU89125.d.cts.map +1 -0
  88. package/dist/{pagination-BAFX7C1I.d.cts → pagination-CrU89125.d.mts} +14 -4
  89. package/dist/pagination-CrU89125.d.mts.map +1 -0
  90. package/dist/pagination.cjs +55 -4
  91. package/dist/pagination.cjs.map +1 -0
  92. package/dist/pagination.d.cts +2 -2
  93. package/dist/pagination.d.mts +2 -2
  94. package/dist/pagination.mjs +51 -2
  95. package/dist/pagination.mjs.map +1 -0
  96. package/dist/pg/roles.cjs +26 -3
  97. package/dist/pg/roles.cjs.map +1 -1
  98. package/dist/pg/roles.d.cts +16 -8
  99. package/dist/pg/roles.d.cts.map +1 -1
  100. package/dist/pg/roles.d.mts +16 -8
  101. package/dist/pg/roles.d.mts.map +1 -1
  102. package/dist/pg/roles.mjs +25 -2
  103. package/dist/pg/roles.mjs.map +1 -1
  104. package/dist/pg/url.cjs +18 -3
  105. package/dist/pg/url.cjs.map +1 -1
  106. package/dist/pg/url.d.cts +2 -4
  107. package/dist/pg/url.d.cts.map +1 -1
  108. package/dist/pg/url.d.mts +2 -4
  109. package/dist/pg/url.d.mts.map +1 -1
  110. package/dist/pg/url.mjs +17 -3
  111. package/dist/pg/url.mjs.map +1 -1
  112. package/dist/rls.cjs +5 -29
  113. package/dist/rls.cjs.map +1 -1
  114. package/dist/rls.d.cts +6 -10
  115. package/dist/rls.d.cts.map +1 -1
  116. package/dist/rls.d.mts +6 -10
  117. package/dist/rls.d.mts.map +1 -1
  118. package/dist/rls.mjs +3 -5
  119. package/dist/rls.mjs.map +1 -1
  120. package/package.json +12 -2
  121. package/dist/errors-CfFCan-X.mjs +0 -69
  122. package/dist/errors-CfFCan-X.mjs.map +0 -1
  123. package/dist/errors-Cs5jlAv-.cjs +0 -93
  124. package/dist/errors-Cs5jlAv-.cjs.map +0 -1
  125. package/dist/kysely-BAedYsvn.d.mts +0 -13
  126. package/dist/kysely-BAedYsvn.d.mts.map +0 -1
  127. package/dist/kysely-Bo_Zg-D3.mjs +0 -11
  128. package/dist/kysely-Bo_Zg-D3.mjs.map +0 -1
  129. package/dist/kysely-D3eeuId4.d.cts +0 -13
  130. package/dist/kysely-D3eeuId4.d.cts.map +0 -1
  131. package/dist/kysely-LWvS2ekY.cjs +0 -17
  132. package/dist/kysely-LWvS2ekY.cjs.map +0 -1
  133. package/dist/pagination-BAFX7C1I.d.cts.map +0 -1
  134. package/dist/pagination-Bu1dqOy0.cjs +0 -55
  135. package/dist/pagination-Bu1dqOy0.cjs.map +0 -1
  136. package/dist/pagination-CQ8QHXaL.mjs +0 -37
  137. package/dist/pagination-CQ8QHXaL.mjs.map +0 -1
  138. package/dist/pagination-kv-KWS-7.d.mts.map +0 -1
package/dist/pg/roles.cjs CHANGED
@@ -1,4 +1,4 @@
1
-
1
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
2
  //#region src/pg/roles.ts
3
3
  /**
4
4
  * Everything one tenant's roles need, in dependency order.
@@ -8,18 +8,20 @@
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
12
  return [
13
13
  {
14
14
  describe: `role ${owner}`,
15
15
  exists: roleExists(owner),
16
16
  sql: `CREATE ROLE ${ident(owner)} LOGIN PASSWORD ${literal(passwords.owner)}`
17
17
  },
18
+ passwordStatement(owner, passwords.owner),
18
19
  {
19
20
  describe: `role ${runtime}`,
20
21
  exists: roleExists(runtime),
21
22
  sql: `CREATE ROLE ${ident(runtime)} LOGIN PASSWORD ${literal(passwords.runtime)}`
22
23
  },
24
+ passwordStatement(runtime, passwords.runtime),
23
25
  {
24
26
  describe: `${owner} granted to the connecting role`,
25
27
  sql: `GRANT ${ident(owner)} TO CURRENT_USER`
@@ -36,6 +38,10 @@ function roleStatements(spec) {
36
38
  describe: `${schema} is owned by ${owner}`,
37
39
  sql: `ALTER SCHEMA ${ident(schema)} OWNER TO ${ident(owner)}`
38
40
  },
41
+ ...database ? [{
42
+ describe: `${owner} may create in database ${database}`,
43
+ sql: `GRANT CREATE ON DATABASE ${ident(database)} TO ${ident(owner)}`
44
+ }] : [],
39
45
  {
40
46
  describe: `${runtime} may use ${schema}`,
41
47
  sql: `GRANT USAGE ON SCHEMA ${ident(schema)} TO ${ident(runtime)}`
@@ -68,6 +74,21 @@ function roleStatements(spec) {
68
74
  ];
69
75
  }
70
76
  /**
77
+ * The role's password, set to what the caller supplied — every time.
78
+ *
79
+ * `CREATE ROLE` only runs for a role that is not there, so on its own a role
80
+ * keeps the password it was born with, and an app handed a different one is
81
+ * locked out. Setting it on every apply is what lets the supplied password be
82
+ * the truth: rotated, or re-derived, the role follows. Idempotent, so it needs
83
+ * no check.
84
+ */
85
+ function passwordStatement(role, password) {
86
+ return {
87
+ describe: `${role} has its current password`,
88
+ sql: `ALTER ROLE ${ident(role)} WITH LOGIN PASSWORD ${literal(password)}`
89
+ };
90
+ }
91
+ /**
71
92
  * A role that may read and nothing else.
72
93
  *
73
94
  * `SELECT` and no more, on what exists and on what the owner creates later. No
@@ -85,6 +106,7 @@ function readerStatements(spec, reader) {
85
106
  exists: roleExists(reader),
86
107
  sql: `CREATE ROLE ${ident(reader)} LOGIN PASSWORD ${literal(password)}`
87
108
  },
109
+ passwordStatement(reader, password),
88
110
  {
89
111
  describe: `${reader} may use ${schema}`,
90
112
  sql: `GRANT USAGE ON SCHEMA ${ident(schema)} TO ${ident(reader)}`
@@ -105,6 +127,7 @@ function readerStatements(spec, reader) {
105
127
  }
106
128
  /** A reader role was named and no password was supplied for it. */
107
129
  var ReaderNeedsPassword = class extends Error {
130
+ role;
108
131
  constructor(role) {
109
132
  super(`'${role}' is a reader role and no password was supplied for it. This module composes DDL and invents no secrets: pass passwords.reader alongside the role name.`);
110
133
  this.role = role;
@@ -145,10 +168,10 @@ function ident(name) {
145
168
  function literal(value) {
146
169
  return `'${value.replace(/'/g, "''")}'`;
147
170
  }
148
-
149
171
  //#endregion
150
172
  exports.ReaderNeedsPassword = ReaderNeedsPassword;
151
173
  exports.ownerRole = ownerRole;
152
174
  exports.readerRole = readerRole;
153
175
  exports.roleStatements = roleStatements;
176
+
154
177
  //# sourceMappingURL=roles.cjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"roles.cjs","names":["spec: RoleSpec","reader: string","role: string","runtime: string","name: string","value: string"],"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/** 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\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{\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 * 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 ${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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"}
@@ -19,7 +19,7 @@
19
19
  * the one part that legitimately differs between a laptop and a deploy.
20
20
  */
21
21
  /** The roles and schema one tenant needs. */
22
- interface RoleSpec {
22
+ export interface RoleSpec {
23
23
  /** The role a handler connects as. Reads and writes rows; owns nothing. */
24
24
  runtime: string;
25
25
  /** The role a migrator connects as. Owns the schema and everything in it. */
@@ -35,6 +35,16 @@ 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;
38
48
  /** Supplied, never generated here. */
39
49
  passwords: {
40
50
  runtime: string;
@@ -43,7 +53,7 @@ interface RoleSpec {
43
53
  };
44
54
  }
45
55
  /** One statement, and how to tell whether it still needs running. */
46
- interface RoleStatement {
56
+ export interface RoleStatement {
47
57
  /** What it does, for the line a reconciler prints. */
48
58
  describe: string;
49
59
  /**
@@ -66,17 +76,15 @@ interface RoleStatement {
66
76
  * caller applies them in sequence and may stop at the first failure without
67
77
  * leaving a half-granted role that looks complete.
68
78
  */
69
- declare function roleStatements(spec: RoleSpec): RoleStatement[];
79
+ export declare function roleStatements(spec: RoleSpec): RoleStatement[];
70
80
  /** A reader role was named and no password was supplied for it. */
71
- declare class ReaderNeedsPassword extends Error {
81
+ export declare class ReaderNeedsPassword extends Error {
72
82
  readonly role: string;
73
83
  constructor(role: string);
74
84
  }
75
85
  /** The read-only role's name for a given runtime role. */
76
- declare function readerRole(runtime: string): string;
86
+ export declare function readerRole(runtime: string): string;
77
87
  /** The owner role's name for a given runtime role. */
78
- declare function ownerRole(runtime: string): string;
79
- //# sourceMappingURL=roles.d.ts.map
88
+ export declare function ownerRole(runtime: string): string;
80
89
  //#endregion
81
- export { ReaderNeedsPassword, RoleSpec, RoleStatement, ownerRole, readerRole, roleStatements };
82
90
  //# sourceMappingURL=roles.d.cts.map
@@ -1 +1 @@
1
- {"version":3,"file":"roles.d.cts","names":[],"sources":["../../src/pg/roles.ts"],"sourcesContent":[],"mappings":";;AAqBA;AAqBA;AAqBA;;;;AAA6D;AA2I7D;AAYA;AAKA;;;;;;;;;;UAtMiB,QAAA;;;;;;;;;;;;;;;;;;;;;;;;UAqBA,aAAA;;;;;;;;;;;;;;;;;;;;;;;iBAqBD,cAAA,OAAqB,WAAW;;cA2InC,mBAAA,SAA4B,KAAK;;;;;iBAY9B,UAAA;;iBAKA,SAAA"}
1
+ {"version":3,"file":"roles.d.cts","names":[],"sources":["../../src/pg/roles.ts"],"mappings":";;;;;;;;;;;;;;;;;;;;;iBAqBiB;;EAEhB;;EAEA;;;;;;;;;EASA;;EAEA;;;;;;;;;;EAUA;;EAEA;IAAa;IAAiB;IAAe;;;;iBAI7B;;EAEhB;;;;;;;;EAQA;IAAW;IAAa;;EACxB;;;;;;;;;wBAUe,eAAe,MAAM,WAAW;;qBAyKnC,4BAA4B;WACnB;EAArB,YAAqB;;;wBAWN,WAAW;;wBAKX,UAAU"}
@@ -19,7 +19,7 @@
19
19
  * the one part that legitimately differs between a laptop and a deploy.
20
20
  */
21
21
  /** The roles and schema one tenant needs. */
22
- interface RoleSpec {
22
+ export interface RoleSpec {
23
23
  /** The role a handler connects as. Reads and writes rows; owns nothing. */
24
24
  runtime: string;
25
25
  /** The role a migrator connects as. Owns the schema and everything in it. */
@@ -35,6 +35,16 @@ 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;
38
48
  /** Supplied, never generated here. */
39
49
  passwords: {
40
50
  runtime: string;
@@ -43,7 +53,7 @@ interface RoleSpec {
43
53
  };
44
54
  }
45
55
  /** One statement, and how to tell whether it still needs running. */
46
- interface RoleStatement {
56
+ export interface RoleStatement {
47
57
  /** What it does, for the line a reconciler prints. */
48
58
  describe: string;
49
59
  /**
@@ -66,17 +76,15 @@ interface RoleStatement {
66
76
  * caller applies them in sequence and may stop at the first failure without
67
77
  * leaving a half-granted role that looks complete.
68
78
  */
69
- declare function roleStatements(spec: RoleSpec): RoleStatement[];
79
+ export declare function roleStatements(spec: RoleSpec): RoleStatement[];
70
80
  /** A reader role was named and no password was supplied for it. */
71
- declare class ReaderNeedsPassword extends Error {
81
+ export declare class ReaderNeedsPassword extends Error {
72
82
  readonly role: string;
73
83
  constructor(role: string);
74
84
  }
75
85
  /** The read-only role's name for a given runtime role. */
76
- declare function readerRole(runtime: string): string;
86
+ export declare function readerRole(runtime: string): string;
77
87
  /** The owner role's name for a given runtime role. */
78
- declare function ownerRole(runtime: string): string;
79
- //# sourceMappingURL=roles.d.ts.map
88
+ export declare function ownerRole(runtime: string): string;
80
89
  //#endregion
81
- export { ReaderNeedsPassword, RoleSpec, RoleStatement, ownerRole, readerRole, roleStatements };
82
90
  //# sourceMappingURL=roles.d.mts.map
@@ -1 +1 @@
1
- {"version":3,"file":"roles.d.mts","names":[],"sources":["../../src/pg/roles.ts"],"sourcesContent":[],"mappings":";;AAqBA;AAqBA;AAqBA;;;;AAA6D;AA2I7D;AAYA;AAKA;;;;;;;;;;UAtMiB,QAAA;;;;;;;;;;;;;;;;;;;;;;;;UAqBA,aAAA;;;;;;;;;;;;;;;;;;;;;;;iBAqBD,cAAA,OAAqB,WAAW;;cA2InC,mBAAA,SAA4B,KAAK;;;;;iBAY9B,UAAA;;iBAKA,SAAA"}
1
+ {"version":3,"file":"roles.d.mts","names":[],"sources":["../../src/pg/roles.ts"],"mappings":";;;;;;;;;;;;;;;;;;;;;iBAqBiB;;EAEhB;;EAEA;;;;;;;;;EASA;;EAEA;;;;;;;;;;EAUA;;EAEA;IAAa;IAAiB;IAAe;;;;iBAI7B;;EAEhB;;;;;;;;EAQA;IAAW;IAAa;;EACxB;;;;;;;;;wBAUe,eAAe,MAAM,WAAW;;qBAyKnC,4BAA4B;WACnB;EAArB,YAAqB;;;wBAWN,WAAW;;wBAKX,UAAU"}
package/dist/pg/roles.mjs CHANGED
@@ -7,18 +7,20 @@
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
11
  return [
12
12
  {
13
13
  describe: `role ${owner}`,
14
14
  exists: roleExists(owner),
15
15
  sql: `CREATE ROLE ${ident(owner)} LOGIN PASSWORD ${literal(passwords.owner)}`
16
16
  },
17
+ passwordStatement(owner, passwords.owner),
17
18
  {
18
19
  describe: `role ${runtime}`,
19
20
  exists: roleExists(runtime),
20
21
  sql: `CREATE ROLE ${ident(runtime)} LOGIN PASSWORD ${literal(passwords.runtime)}`
21
22
  },
23
+ passwordStatement(runtime, passwords.runtime),
22
24
  {
23
25
  describe: `${owner} granted to the connecting role`,
24
26
  sql: `GRANT ${ident(owner)} TO CURRENT_USER`
@@ -35,6 +37,10 @@ function roleStatements(spec) {
35
37
  describe: `${schema} is owned by ${owner}`,
36
38
  sql: `ALTER SCHEMA ${ident(schema)} OWNER TO ${ident(owner)}`
37
39
  },
40
+ ...database ? [{
41
+ describe: `${owner} may create in database ${database}`,
42
+ sql: `GRANT CREATE ON DATABASE ${ident(database)} TO ${ident(owner)}`
43
+ }] : [],
38
44
  {
39
45
  describe: `${runtime} may use ${schema}`,
40
46
  sql: `GRANT USAGE ON SCHEMA ${ident(schema)} TO ${ident(runtime)}`
@@ -67,6 +73,21 @@ function roleStatements(spec) {
67
73
  ];
68
74
  }
69
75
  /**
76
+ * The role's password, set to what the caller supplied — every time.
77
+ *
78
+ * `CREATE ROLE` only runs for a role that is not there, so on its own a role
79
+ * keeps the password it was born with, and an app handed a different one is
80
+ * locked out. Setting it on every apply is what lets the supplied password be
81
+ * the truth: rotated, or re-derived, the role follows. Idempotent, so it needs
82
+ * no check.
83
+ */
84
+ function passwordStatement(role, password) {
85
+ return {
86
+ describe: `${role} has its current password`,
87
+ sql: `ALTER ROLE ${ident(role)} WITH LOGIN PASSWORD ${literal(password)}`
88
+ };
89
+ }
90
+ /**
70
91
  * A role that may read and nothing else.
71
92
  *
72
93
  * `SELECT` and no more, on what exists and on what the owner creates later. No
@@ -84,6 +105,7 @@ function readerStatements(spec, reader) {
84
105
  exists: roleExists(reader),
85
106
  sql: `CREATE ROLE ${ident(reader)} LOGIN PASSWORD ${literal(password)}`
86
107
  },
108
+ passwordStatement(reader, password),
87
109
  {
88
110
  describe: `${reader} may use ${schema}`,
89
111
  sql: `GRANT USAGE ON SCHEMA ${ident(schema)} TO ${ident(reader)}`
@@ -104,6 +126,7 @@ function readerStatements(spec, reader) {
104
126
  }
105
127
  /** A reader role was named and no password was supplied for it. */
106
128
  var ReaderNeedsPassword = class extends Error {
129
+ role;
107
130
  constructor(role) {
108
131
  super(`'${role}' is a reader role and no password was supplied for it. This module composes DDL and invents no secrets: pass passwords.reader alongside the role name.`);
109
132
  this.role = role;
@@ -144,7 +167,7 @@ function ident(name) {
144
167
  function literal(value) {
145
168
  return `'${value.replace(/'/g, "''")}'`;
146
169
  }
147
-
148
170
  //#endregion
149
171
  export { ReaderNeedsPassword, ownerRole, readerRole, roleStatements };
172
+
150
173
  //# sourceMappingURL=roles.mjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"roles.mjs","names":["spec: RoleSpec","reader: string","role: string","runtime: string","name: string","value: string"],"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/** 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\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{\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 * 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 ${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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"}
package/dist/pg/url.cjs CHANGED
@@ -1,6 +1,21 @@
1
- const require_errors = require('../errors-Cs5jlAv-.cjs');
2
-
1
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
+ const require_errors = require("../errors.cjs");
3
3
  //#region src/pg/url.ts
4
+ /**
5
+ * Composing a `postgres://` connection string.
6
+ *
7
+ * One direction only. Parsing exists in `pg` already — every consumer hands the
8
+ * string straight to `new Pool({ connectionString })` — so a parser here would
9
+ * be surface whose only caller is its own round-trip test.
10
+ *
11
+ * What is *not* already solved is composition: a provisioned instance exposes
12
+ * host, port, database, username, and password as five separate values, and the
13
+ * construct reads one URL. Doing that join by hand is where `search_path`
14
+ * quietly goes missing.
15
+ *
16
+ * A URL built here is a secret — it carries the role's password. See
17
+ * {@link redactDatabaseUrl} before writing one anywhere.
18
+ */
4
19
  const SCHEME = "postgres:";
5
20
  const DEFAULT_PORT = 5432;
6
21
  /**
@@ -27,7 +42,7 @@ function build(address) {
27
42
  if (ssl === false) url.searchParams.set("sslmode", "disable");
28
43
  return url.toString();
29
44
  }
30
-
31
45
  //#endregion
32
46
  exports.build = build;
47
+
33
48
  //# sourceMappingURL=url.cjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"url.cjs","names":["schema: string","address: PostgresAddress","MissingDatabaseHost","MissingDatabaseName"],"sources":["../../src/pg/url.ts"],"sourcesContent":["/**\n * Composing a `postgres://` connection string.\n *\n * One direction only. Parsing exists in `pg` already — every consumer hands the\n * string straight to `new Pool({ connectionString })` — so a parser here would\n * be surface whose only caller is its own round-trip test.\n *\n * What is *not* already solved is composition: a provisioned instance exposes\n * host, port, database, username, and password as five separate values, and the\n * construct reads one URL. Doing that join by hand is where `search_path`\n * quietly goes missing.\n *\n * A URL built here is a secret — it carries the role's password. See\n * {@link redactDatabaseUrl} before writing one anywhere.\n */\n\nimport { MissingDatabaseHost, MissingDatabaseName } from '../errors';\n\n/** What a Postgres connection string addresses. */\nexport interface PostgresAddress {\n\thost: string;\n\tport?: number;\n\t/** The logical database. */\n\tdatabase: string;\n\t/** The role connecting — which is what decides DDL versus DML. */\n\tusername?: string;\n\tpassword?: string;\n\t/**\n\t * The schema to resolve unqualified names against.\n\t *\n\t * Normally pinned on the *role* instead (`ALTER ROLE … SET search_path`), so\n\t * it does not appear in the URL at all. It is carried here for the one case\n\t * with no role of its own to pin — connecting as the cluster master under\n\t * `roles: false` — where without it every query looks in `public`.\n\t *\n\t * Postgres-specific by nature, not just by spelling: it becomes a libpq\n\t * connection option, and no other engine accepts one. MySQL has no schemas\n\t * distinct from databases at all.\n\t */\n\tsearchPath?: string;\n\t/** Off only for local development, where there is no certificate to verify. */\n\tssl?: boolean;\n}\n\nconst SCHEME = 'postgres:';\n\nconst DEFAULT_PORT = 5432;\n\n/**\n * `search_path` travels as a libpq connection option, not a query parameter.\n * The server reads `options`; a plain `?search_path=app` is accepted by the URL\n * parser, ignored by the server, and produces a database that looks empty.\n * The `-c` prefix is what makes it a runtime configuration setting.\n */\nfunction searchPathOption(schema: string): string {\n\treturn `-c search_path=${schema}`;\n}\n\n/** Compose an address into a connection string. */\nexport function build(address: PostgresAddress): string {\n\tconst { host, port, database, username, password, searchPath, ssl } = address;\n\n\tif (!host) throw new MissingDatabaseHost();\n\tif (!database) throw new MissingDatabaseName();\n\n\tconst url = new URL(`${SCHEME}//`);\n\turl.hostname = host;\n\tif (port && port !== DEFAULT_PORT) url.port = String(port);\n\t// Username before password: WHATWG URL drops a password set without one.\n\tif (username) url.username = encodeURIComponent(username);\n\tif (password) url.password = encodeURIComponent(password);\n\turl.pathname = `/${database}`;\n\n\tif (searchPath) url.searchParams.set('options', searchPathOption(searchPath));\n\tif (ssl === false) url.searchParams.set('sslmode', 'disable');\n\n\treturn url.toString();\n}\n"],"mappings":";;;AA4CA,MAAM,SAAS;AAEf,MAAM,eAAe;;;;;;;AAQrB,SAAS,iBAAiBA,QAAwB;AACjD,SAAQ,iBAAiB,OAAO;AAChC;;AAGD,SAAgB,MAAMC,SAAkC;CACvD,MAAM,EAAE,MAAM,MAAM,UAAU,UAAU,UAAU,YAAY,KAAK,GAAG;AAEtE,MAAK,KAAM,OAAM,IAAIC;AACrB,MAAK,SAAU,OAAM,IAAIC;CAEzB,MAAM,MAAM,IAAI,KAAK,EAAE,OAAO;AAC9B,KAAI,WAAW;AACf,KAAI,QAAQ,SAAS,aAAc,KAAI,OAAO,OAAO,KAAK;AAE1D,KAAI,SAAU,KAAI,WAAW,mBAAmB,SAAS;AACzD,KAAI,SAAU,KAAI,WAAW,mBAAmB,SAAS;AACzD,KAAI,YAAY,GAAG,SAAS;AAE5B,KAAI,WAAY,KAAI,aAAa,IAAI,WAAW,iBAAiB,WAAW,CAAC;AAC7E,KAAI,QAAQ,MAAO,KAAI,aAAa,IAAI,WAAW,UAAU;AAE7D,QAAO,IAAI,UAAU;AACrB"}
1
+ {"version":3,"file":"url.cjs","names":["MissingDatabaseHost","MissingDatabaseName"],"sources":["../../src/pg/url.ts"],"sourcesContent":["/**\n * Composing a `postgres://` connection string.\n *\n * One direction only. Parsing exists in `pg` already — every consumer hands the\n * string straight to `new Pool({ connectionString })` — so a parser here would\n * be surface whose only caller is its own round-trip test.\n *\n * What is *not* already solved is composition: a provisioned instance exposes\n * host, port, database, username, and password as five separate values, and the\n * construct reads one URL. Doing that join by hand is where `search_path`\n * quietly goes missing.\n *\n * A URL built here is a secret — it carries the role's password. See\n * {@link redactDatabaseUrl} before writing one anywhere.\n */\n\nimport { MissingDatabaseHost, MissingDatabaseName } from '../errors';\n\n/** What a Postgres connection string addresses. */\nexport interface PostgresAddress {\n\thost: string;\n\tport?: number;\n\t/** The logical database. */\n\tdatabase: string;\n\t/** The role connecting — which is what decides DDL versus DML. */\n\tusername?: string;\n\tpassword?: string;\n\t/**\n\t * The schema to resolve unqualified names against.\n\t *\n\t * Normally pinned on the *role* instead (`ALTER ROLE … SET search_path`), so\n\t * it does not appear in the URL at all. It is carried here for the one case\n\t * with no role of its own to pin — connecting as the cluster master under\n\t * `roles: false` — where without it every query looks in `public`.\n\t *\n\t * Postgres-specific by nature, not just by spelling: it becomes a libpq\n\t * connection option, and no other engine accepts one. MySQL has no schemas\n\t * distinct from databases at all.\n\t */\n\tsearchPath?: string;\n\t/** Off only for local development, where there is no certificate to verify. */\n\tssl?: boolean;\n}\n\nconst SCHEME = 'postgres:';\n\nconst DEFAULT_PORT = 5432;\n\n/**\n * `search_path` travels as a libpq connection option, not a query parameter.\n * The server reads `options`; a plain `?search_path=app` is accepted by the URL\n * parser, ignored by the server, and produces a database that looks empty.\n * The `-c` prefix is what makes it a runtime configuration setting.\n */\nfunction searchPathOption(schema: string): string {\n\treturn `-c search_path=${schema}`;\n}\n\n/** Compose an address into a connection string. */\nexport function build(address: PostgresAddress): string {\n\tconst { host, port, database, username, password, searchPath, ssl } = address;\n\n\tif (!host) throw new MissingDatabaseHost();\n\tif (!database) throw new MissingDatabaseName();\n\n\tconst url = new URL(`${SCHEME}//`);\n\turl.hostname = host;\n\tif (port && port !== DEFAULT_PORT) url.port = String(port);\n\t// Username before password: WHATWG URL drops a password set without one.\n\tif (username) url.username = encodeURIComponent(username);\n\tif (password) url.password = encodeURIComponent(password);\n\turl.pathname = `/${database}`;\n\n\tif (searchPath) url.searchParams.set('options', searchPathOption(searchPath));\n\tif (ssl === false) url.searchParams.set('sslmode', 'disable');\n\n\treturn url.toString();\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;AA4CA,MAAM,SAAS;AAEf,MAAM,eAAe;;;;;;;AAQrB,SAAS,iBAAiB,QAAwB;CACjD,OAAO,kBAAkB;AAC1B;;AAGA,SAAgB,MAAM,SAAkC;CACvD,MAAM,EAAE,MAAM,MAAM,UAAU,UAAU,UAAU,YAAY,QAAQ;CAEtE,IAAI,CAAC,MAAM,MAAM,IAAIA,eAAAA,oBAAoB;CACzC,IAAI,CAAC,UAAU,MAAM,IAAIC,eAAAA,oBAAoB;CAE7C,MAAM,MAAM,IAAI,IAAI,GAAG,OAAO,GAAG;CACjC,IAAI,WAAW;CACf,IAAI,QAAQ,SAAS,cAAc,IAAI,OAAO,OAAO,IAAI;CAEzD,IAAI,UAAU,IAAI,WAAW,mBAAmB,QAAQ;CACxD,IAAI,UAAU,IAAI,WAAW,mBAAmB,QAAQ;CACxD,IAAI,WAAW,IAAI;CAEnB,IAAI,YAAY,IAAI,aAAa,IAAI,WAAW,iBAAiB,UAAU,CAAC;CAC5E,IAAI,QAAQ,OAAO,IAAI,aAAa,IAAI,WAAW,SAAS;CAE5D,OAAO,IAAI,SAAS;AACrB"}
package/dist/pg/url.d.cts CHANGED
@@ -15,7 +15,7 @@
15
15
  * {@link redactDatabaseUrl} before writing one anywhere.
16
16
  */
17
17
  /** What a Postgres connection string addresses. */
18
- interface PostgresAddress {
18
+ export interface PostgresAddress {
19
19
  host: string;
20
20
  port?: number;
21
21
  /** The logical database. */
@@ -40,8 +40,6 @@ interface PostgresAddress {
40
40
  ssl?: boolean;
41
41
  }
42
42
  /** Compose an address into a connection string. */
43
- declare function build(address: PostgresAddress): string;
44
- //# sourceMappingURL=url.d.ts.map
43
+ export declare function build(address: PostgresAddress): string;
45
44
  //#endregion
46
- export { PostgresAddress, build };
47
45
  //# sourceMappingURL=url.d.cts.map
@@ -1 +1 @@
1
- {"version":3,"file":"url.d.cts","names":[],"sources":["../../src/pg/url.ts"],"sourcesContent":[],"mappings":";;AAmBA;AAwCA;;;;;;;;;;;;;;UAxCiB,eAAA;;;;;;;;;;;;;;;;;;;;;;;;;iBAwCD,KAAA,UAAe"}
1
+ {"version":3,"file":"url.d.cts","names":[],"sources":["../../src/pg/url.ts"],"mappings":";;;;;;;;;;;;;;;;;iBAmBiB;EAChB;EACA;;EAEA;;EAEA;EACA;;;;;;;;;;;;;EAaA;;EAEA;;;wBAkBe,MAAM,SAAS"}
package/dist/pg/url.d.mts CHANGED
@@ -15,7 +15,7 @@
15
15
  * {@link redactDatabaseUrl} before writing one anywhere.
16
16
  */
17
17
  /** What a Postgres connection string addresses. */
18
- interface PostgresAddress {
18
+ export interface PostgresAddress {
19
19
  host: string;
20
20
  port?: number;
21
21
  /** The logical database. */
@@ -40,8 +40,6 @@ interface PostgresAddress {
40
40
  ssl?: boolean;
41
41
  }
42
42
  /** Compose an address into a connection string. */
43
- declare function build(address: PostgresAddress): string;
44
- //# sourceMappingURL=url.d.ts.map
43
+ export declare function build(address: PostgresAddress): string;
45
44
  //#endregion
46
- export { PostgresAddress, build };
47
45
  //# sourceMappingURL=url.d.mts.map
@@ -1 +1 @@
1
- {"version":3,"file":"url.d.mts","names":[],"sources":["../../src/pg/url.ts"],"sourcesContent":[],"mappings":";;AAmBA;AAwCA;;;;;;;;;;;;;;UAxCiB,eAAA;;;;;;;;;;;;;;;;;;;;;;;;;iBAwCD,KAAA,UAAe"}
1
+ {"version":3,"file":"url.d.mts","names":[],"sources":["../../src/pg/url.ts"],"mappings":";;;;;;;;;;;;;;;;;iBAmBiB;EAChB;EACA;;EAEA;;EAEA;EACA;;;;;;;;;;;;;EAaA;;EAEA;;;wBAkBe,MAAM,SAAS"}
package/dist/pg/url.mjs CHANGED
@@ -1,6 +1,20 @@
1
- import { MissingDatabaseHost, MissingDatabaseName } from "../errors-CfFCan-X.mjs";
2
-
1
+ import { MissingDatabaseHost, MissingDatabaseName } from "../errors.mjs";
3
2
  //#region src/pg/url.ts
3
+ /**
4
+ * Composing a `postgres://` connection string.
5
+ *
6
+ * One direction only. Parsing exists in `pg` already — every consumer hands the
7
+ * string straight to `new Pool({ connectionString })` — so a parser here would
8
+ * be surface whose only caller is its own round-trip test.
9
+ *
10
+ * What is *not* already solved is composition: a provisioned instance exposes
11
+ * host, port, database, username, and password as five separate values, and the
12
+ * construct reads one URL. Doing that join by hand is where `search_path`
13
+ * quietly goes missing.
14
+ *
15
+ * A URL built here is a secret — it carries the role's password. See
16
+ * {@link redactDatabaseUrl} before writing one anywhere.
17
+ */
4
18
  const SCHEME = "postgres:";
5
19
  const DEFAULT_PORT = 5432;
6
20
  /**
@@ -27,7 +41,7 @@ function build(address) {
27
41
  if (ssl === false) url.searchParams.set("sslmode", "disable");
28
42
  return url.toString();
29
43
  }
30
-
31
44
  //#endregion
32
45
  export { build };
46
+
33
47
  //# sourceMappingURL=url.mjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"url.mjs","names":["schema: string","address: PostgresAddress"],"sources":["../../src/pg/url.ts"],"sourcesContent":["/**\n * Composing a `postgres://` connection string.\n *\n * One direction only. Parsing exists in `pg` already — every consumer hands the\n * string straight to `new Pool({ connectionString })` — so a parser here would\n * be surface whose only caller is its own round-trip test.\n *\n * What is *not* already solved is composition: a provisioned instance exposes\n * host, port, database, username, and password as five separate values, and the\n * construct reads one URL. Doing that join by hand is where `search_path`\n * quietly goes missing.\n *\n * A URL built here is a secret — it carries the role's password. See\n * {@link redactDatabaseUrl} before writing one anywhere.\n */\n\nimport { MissingDatabaseHost, MissingDatabaseName } from '../errors';\n\n/** What a Postgres connection string addresses. */\nexport interface PostgresAddress {\n\thost: string;\n\tport?: number;\n\t/** The logical database. */\n\tdatabase: string;\n\t/** The role connecting — which is what decides DDL versus DML. */\n\tusername?: string;\n\tpassword?: string;\n\t/**\n\t * The schema to resolve unqualified names against.\n\t *\n\t * Normally pinned on the *role* instead (`ALTER ROLE … SET search_path`), so\n\t * it does not appear in the URL at all. It is carried here for the one case\n\t * with no role of its own to pin — connecting as the cluster master under\n\t * `roles: false` — where without it every query looks in `public`.\n\t *\n\t * Postgres-specific by nature, not just by spelling: it becomes a libpq\n\t * connection option, and no other engine accepts one. MySQL has no schemas\n\t * distinct from databases at all.\n\t */\n\tsearchPath?: string;\n\t/** Off only for local development, where there is no certificate to verify. */\n\tssl?: boolean;\n}\n\nconst SCHEME = 'postgres:';\n\nconst DEFAULT_PORT = 5432;\n\n/**\n * `search_path` travels as a libpq connection option, not a query parameter.\n * The server reads `options`; a plain `?search_path=app` is accepted by the URL\n * parser, ignored by the server, and produces a database that looks empty.\n * The `-c` prefix is what makes it a runtime configuration setting.\n */\nfunction searchPathOption(schema: string): string {\n\treturn `-c search_path=${schema}`;\n}\n\n/** Compose an address into a connection string. */\nexport function build(address: PostgresAddress): string {\n\tconst { host, port, database, username, password, searchPath, ssl } = address;\n\n\tif (!host) throw new MissingDatabaseHost();\n\tif (!database) throw new MissingDatabaseName();\n\n\tconst url = new URL(`${SCHEME}//`);\n\turl.hostname = host;\n\tif (port && port !== DEFAULT_PORT) url.port = String(port);\n\t// Username before password: WHATWG URL drops a password set without one.\n\tif (username) url.username = encodeURIComponent(username);\n\tif (password) url.password = encodeURIComponent(password);\n\turl.pathname = `/${database}`;\n\n\tif (searchPath) url.searchParams.set('options', searchPathOption(searchPath));\n\tif (ssl === false) url.searchParams.set('sslmode', 'disable');\n\n\treturn url.toString();\n}\n"],"mappings":";;;AA4CA,MAAM,SAAS;AAEf,MAAM,eAAe;;;;;;;AAQrB,SAAS,iBAAiBA,QAAwB;AACjD,SAAQ,iBAAiB,OAAO;AAChC;;AAGD,SAAgB,MAAMC,SAAkC;CACvD,MAAM,EAAE,MAAM,MAAM,UAAU,UAAU,UAAU,YAAY,KAAK,GAAG;AAEtE,MAAK,KAAM,OAAM,IAAI;AACrB,MAAK,SAAU,OAAM,IAAI;CAEzB,MAAM,MAAM,IAAI,KAAK,EAAE,OAAO;AAC9B,KAAI,WAAW;AACf,KAAI,QAAQ,SAAS,aAAc,KAAI,OAAO,OAAO,KAAK;AAE1D,KAAI,SAAU,KAAI,WAAW,mBAAmB,SAAS;AACzD,KAAI,SAAU,KAAI,WAAW,mBAAmB,SAAS;AACzD,KAAI,YAAY,GAAG,SAAS;AAE5B,KAAI,WAAY,KAAI,aAAa,IAAI,WAAW,iBAAiB,WAAW,CAAC;AAC7E,KAAI,QAAQ,MAAO,KAAI,aAAa,IAAI,WAAW,UAAU;AAE7D,QAAO,IAAI,UAAU;AACrB"}
1
+ {"version":3,"file":"url.mjs","names":[],"sources":["../../src/pg/url.ts"],"sourcesContent":["/**\n * Composing a `postgres://` connection string.\n *\n * One direction only. Parsing exists in `pg` already — every consumer hands the\n * string straight to `new Pool({ connectionString })` — so a parser here would\n * be surface whose only caller is its own round-trip test.\n *\n * What is *not* already solved is composition: a provisioned instance exposes\n * host, port, database, username, and password as five separate values, and the\n * construct reads one URL. Doing that join by hand is where `search_path`\n * quietly goes missing.\n *\n * A URL built here is a secret — it carries the role's password. See\n * {@link redactDatabaseUrl} before writing one anywhere.\n */\n\nimport { MissingDatabaseHost, MissingDatabaseName } from '../errors';\n\n/** What a Postgres connection string addresses. */\nexport interface PostgresAddress {\n\thost: string;\n\tport?: number;\n\t/** The logical database. */\n\tdatabase: string;\n\t/** The role connecting — which is what decides DDL versus DML. */\n\tusername?: string;\n\tpassword?: string;\n\t/**\n\t * The schema to resolve unqualified names against.\n\t *\n\t * Normally pinned on the *role* instead (`ALTER ROLE … SET search_path`), so\n\t * it does not appear in the URL at all. It is carried here for the one case\n\t * with no role of its own to pin — connecting as the cluster master under\n\t * `roles: false` — where without it every query looks in `public`.\n\t *\n\t * Postgres-specific by nature, not just by spelling: it becomes a libpq\n\t * connection option, and no other engine accepts one. MySQL has no schemas\n\t * distinct from databases at all.\n\t */\n\tsearchPath?: string;\n\t/** Off only for local development, where there is no certificate to verify. */\n\tssl?: boolean;\n}\n\nconst SCHEME = 'postgres:';\n\nconst DEFAULT_PORT = 5432;\n\n/**\n * `search_path` travels as a libpq connection option, not a query parameter.\n * The server reads `options`; a plain `?search_path=app` is accepted by the URL\n * parser, ignored by the server, and produces a database that looks empty.\n * The `-c` prefix is what makes it a runtime configuration setting.\n */\nfunction searchPathOption(schema: string): string {\n\treturn `-c search_path=${schema}`;\n}\n\n/** Compose an address into a connection string. */\nexport function build(address: PostgresAddress): string {\n\tconst { host, port, database, username, password, searchPath, ssl } = address;\n\n\tif (!host) throw new MissingDatabaseHost();\n\tif (!database) throw new MissingDatabaseName();\n\n\tconst url = new URL(`${SCHEME}//`);\n\turl.hostname = host;\n\tif (port && port !== DEFAULT_PORT) url.port = String(port);\n\t// Username before password: WHATWG URL drops a password set without one.\n\tif (username) url.username = encodeURIComponent(username);\n\tif (password) url.password = encodeURIComponent(password);\n\turl.pathname = `/${database}`;\n\n\tif (searchPath) url.searchParams.set('options', searchPathOption(searchPath));\n\tif (ssl === false) url.searchParams.set('sslmode', 'disable');\n\n\treturn url.toString();\n}\n"],"mappings":";;;;;;;;;;;;;;;;;AA4CA,MAAM,SAAS;AAEf,MAAM,eAAe;;;;;;;AAQrB,SAAS,iBAAiB,QAAwB;CACjD,OAAO,kBAAkB;AAC1B;;AAGA,SAAgB,MAAM,SAAkC;CACvD,MAAM,EAAE,MAAM,MAAM,UAAU,UAAU,UAAU,YAAY,QAAQ;CAEtE,IAAI,CAAC,MAAM,MAAM,IAAI,oBAAoB;CACzC,IAAI,CAAC,UAAU,MAAM,IAAI,oBAAoB;CAE7C,MAAM,MAAM,IAAI,IAAI,GAAG,OAAO,GAAG;CACjC,IAAI,WAAW;CACf,IAAI,QAAQ,SAAS,cAAc,IAAI,OAAO,OAAO,IAAI;CAEzD,IAAI,UAAU,IAAI,WAAW,mBAAmB,QAAQ;CACxD,IAAI,UAAU,IAAI,WAAW,mBAAmB,QAAQ;CACxD,IAAI,WAAW,IAAI;CAEnB,IAAI,YAAY,IAAI,aAAa,IAAI,WAAW,iBAAiB,UAAU,CAAC;CAC5E,IAAI,QAAQ,OAAO,IAAI,aAAa,IAAI,WAAW,SAAS;CAE5D,OAAO,IAAI,SAAS;AACrB"}