@andrewheberle/serverless-ssh-ca 0.19.0 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,24 +1,30 @@
1
1
  {
2
2
  "name": "@andrewheberle/serverless-ssh-ca",
3
- "version": "0.19.0",
4
- "description": "A serverless SSH CA built on Cloudflare Workers",
3
+ "version": "0.20.0",
4
+ "description": "A serverless SSH CA built for Cloudflare Workers that can also run on Node.js",
5
5
  "type": "module",
6
6
  "types": "./dist/index.d.ts",
7
7
  "files": [
8
8
  "dist",
9
- "src"
9
+ "src",
10
+ "openapi.json"
10
11
  ],
11
12
  "exports": {
12
13
  ".": {
13
14
  "types": "./dist/index.d.ts",
14
15
  "default": "./dist/index.js"
15
- }
16
+ },
17
+ "./node": {
18
+ "types": "./dist/node/index.d.ts",
19
+ "default": "./dist/node/index.js"
20
+ },
21
+ "./openapi.json": "./openapi.json"
16
22
  },
17
23
  "scripts": {
18
24
  "build": "tsc",
19
25
  "coverage": "vitest run --coverage",
20
26
  "lint": "eslint",
21
- "prepublishOnly": "npm run build",
27
+ "prepublishOnly": "npm run build && npm run schema",
22
28
  "release": "release-it",
23
29
  "release:ci": "npm run release:patch",
24
30
  "release:ci:major": "release-it major --ci",
package/src/index.ts CHANGED
@@ -2,7 +2,7 @@ import type { ExecutionContext } from "hono"
2
2
  import { dbCleanup } from "./db/index.js"
3
3
  import { createApp } from "./router.js"
4
4
  import type { SshCaBindings } from "./types.js"
5
- export type { CaDatabase, CaSecret, SshCaBindings } from "./types.js"
5
+ export type { CaDatabase, CaDatabaseQueryResult, CaDatabaseResult, CaPreparedStatement, CaSecret, SshCaBindings } from "./types.js"
6
6
 
7
7
  // the handlers the CA exposes, compatible with a Cloudflare Workers ExportedHandler
8
8
  type SshCaHandler = {
@@ -10,6 +10,42 @@ type SshCaHandler = {
10
10
  scheduled(controller: unknown, env: SshCaBindings, ctx: ExecutionContext): Promise<void>
11
11
  }
12
12
 
13
+ /**
14
+ * A runtime neutral instance of the CA, returned by {@link createSshCa}.
15
+ */
16
+ export interface SshCa {
17
+ /**
18
+ * Handle a request to the CA's API, suitable for any server that speaks
19
+ * the Fetch API `Request` and `Response` types.
20
+ */
21
+ fetch(req: Request): Promise<Response>
22
+ /**
23
+ * Remove expired certificates from the database according to
24
+ * `DB_CERTIFICATE_RETENTION`. This should be run periodically (for example
25
+ * once a day), as the `scheduled` handler does on Cloudflare Workers.
26
+ */
27
+ cleanup(): Promise<void>
28
+ }
29
+
30
+ /**
31
+ * Create an instance of the CA bound to a fixed set of bindings, for use on
32
+ * runtimes other than Cloudflare Workers.
33
+ *
34
+ * On Workers use the default export instead, which receives its bindings from
35
+ * the runtime on each request.
36
+ *
37
+ * @param env The bindings (configuration, database and private key) for the CA, see {@link SshCaBindings}
38
+ * @returns The CA's request handler and database cleanup task, see {@link SshCa}
39
+ */
40
+ export const createSshCa = (env: SshCaBindings): SshCa => {
41
+ const app = createApp(env)
42
+
43
+ return {
44
+ fetch: async (req: Request): Promise<Response> => await app.fetch(req, env),
45
+ cleanup: async (): Promise<void> => await dbCleanup(env),
46
+ }
47
+ }
48
+
13
49
  let app: ReturnType<typeof createApp> | undefined
14
50
 
15
51
  export default {
@@ -0,0 +1,90 @@
1
+ import { seconds } from "itty-time"
2
+ import z from "zod"
3
+ import type { CaDatabase, CaSecret, SshCaBindings } from "../types.js"
4
+
5
+ const required = z.string().trim().min(1, { message: "must be set" })
6
+
7
+ // a duration in the format accepted by itty-time, such as "24 hours"
8
+ const duration = (opts: { allowZero: boolean }) => required.refine(v => {
9
+ const s = seconds(v)
10
+ return Number.isFinite(s) && (opts.allowZero ? s >= 0 : s > 0)
11
+ }, { message: opts.allowZero ? "must be a duration such as \"90 seconds\"" : "must be a positive duration such as \"24 hours\"" })
12
+
13
+ const boolean = z.enum(["true", "false"], { message: "must be \"true\" or \"false\"" })
14
+
15
+ /**
16
+ * The environment variables read by {@link bindingsFromEnv}, which match the
17
+ * Workers vars documented for the CA.
18
+ */
19
+ export const EnvSchema = z.object({
20
+ DB_CERTIFICATE_RETENTION: z.union([
21
+ z.literal("infinite"),
22
+ // an SQLite date modifier such as "1 year" or "90 days"
23
+ z.string().regex(/^[+-]?\d+(\.\d+)? (second|minute|hour|day|month|year)s?$/),
24
+ ], { message: "must be \"infinite\" or a period such as \"1 year\"" }),
25
+ SSH_CERTIFICATE_EXTENSIONS: z.string(),
26
+ SSH_CERTIFICATE_LIFETIME: duration({ allowZero: false }),
27
+ SSH_CERTIFICATE_INCLUDE_SELF: boolean.optional(),
28
+ SSH_CERTIFICATE_INCLUDE_SELF_EMAIL: boolean.optional(),
29
+ SSH_CERTIFICATE_PRINCIPALS: z.string().optional(),
30
+ SSH_HOST_CERTIFICATE_LIFETIME: duration({ allowZero: false }),
31
+ SSH_HOST_CERTIFICATE_ALLOWED_EMAILS: z.string().optional(),
32
+ SSH_HOST_CERTIFICATE_ALLOWED_ROLES: z.string().optional(),
33
+ ISSUER_DN: required,
34
+ JWT_JWKS_URL: z.url({ protocol: /^https?$/, message: "must be an http or https URL" }),
35
+ JWT_AUD: z.string().optional(),
36
+ JWT_ISSUER: required,
37
+ JWT_ALGORITHMS: required,
38
+ JWT_SSH_CERTIFICATE_PRINCIPALS_CLAIM: required,
39
+ CERTIFICATE_REQUEST_TIME_SKEW_MAX: duration({ allowZero: true }),
40
+ LOG_LEVEL: z.enum(["none", "error", "warning", "info", "debug"]).optional(),
41
+ })
42
+
43
+ /**
44
+ * The CA's configuration as read from environment variables, see {@link EnvSchema}.
45
+ */
46
+ export type CaEnv = z.infer<typeof EnvSchema>
47
+
48
+ /**
49
+ * Thrown by {@link bindingsFromEnv} when the environment is not a valid configuration.
50
+ */
51
+ export class InvalidConfigurationError extends Error {
52
+ constructor(message: string) {
53
+ super(message)
54
+ this.name = "InvalidConfigurationError"
55
+
56
+ Object.setPrototypeOf(this, InvalidConfigurationError.prototype)
57
+ }
58
+ }
59
+
60
+ /**
61
+ * Build the CA's bindings from environment variables, for runtimes where
62
+ * configuration is not supplied as Workers bindings.
63
+ *
64
+ * The variables have the same names and formats as the Workers vars, and are
65
+ * validated before use so that misconfiguration is reported at start up.
66
+ *
67
+ * @example
68
+ * ```ts
69
+ * const env = bindingsFromEnv({
70
+ * DB: fromNodeSqlite(new DatabaseSync("ssh-ca.sqlite")),
71
+ * PRIVATE_KEY: secretFromFile("/etc/ssh-ca/ca_key"),
72
+ * })
73
+ * ```
74
+ *
75
+ * @param bindings The database and private key, see {@link CaDatabase} and {@link CaSecret}
76
+ * @param source The environment variables to read, `process.env` by default
77
+ * @returns The bindings to pass to `createSshCa`
78
+ * @throws {@link InvalidConfigurationError} if a variable is missing or invalid
79
+ */
80
+ export const bindingsFromEnv = (
81
+ bindings: { DB: CaDatabase, PRIVATE_KEY: CaSecret },
82
+ source: Record<string, string | undefined> = process.env,
83
+ ): SshCaBindings => {
84
+ const result = EnvSchema.safeParse(source)
85
+ if (!result.success) {
86
+ throw new InvalidConfigurationError(`invalid configuration:\n${z.prettifyError(result.error)}`)
87
+ }
88
+
89
+ return { ...result.data, DB: bindings.DB, PRIVATE_KEY: bindings.PRIVATE_KEY }
90
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Helpers for running the CA on Node.js, imported from
3
+ * `@andrewheberle/serverless-ssh-ca/node`.
4
+ *
5
+ * @example
6
+ * ```ts
7
+ * import { DatabaseSync } from "node:sqlite"
8
+ * import { createSshCa } from "@andrewheberle/serverless-ssh-ca"
9
+ * import { bindingsFromEnv, fromNodeSqlite, secretFromFile } from "@andrewheberle/serverless-ssh-ca/node"
10
+ *
11
+ * const ca = createSshCa(bindingsFromEnv({
12
+ * DB: fromNodeSqlite(new DatabaseSync("ssh-ca.sqlite")),
13
+ * PRIVATE_KEY: secretFromFile("/etc/ssh-ca/ca_key"),
14
+ * }))
15
+ * ```
16
+ *
17
+ * @packageDocumentation
18
+ */
19
+ export { bindingsFromEnv, EnvSchema, InvalidConfigurationError } from "./env.js"
20
+ export type { CaEnv } from "./env.js"
21
+ export { secretFromFile, secretFromString } from "./secret.js"
22
+ export { fromNodeSqlite } from "./sqlite.js"
@@ -0,0 +1,30 @@
1
+ import { readFile } from "node:fs/promises"
2
+ import type { CaSecret } from "../types.js"
3
+
4
+ /**
5
+ * Use a string as the CA's private key secret.
6
+ *
7
+ * @example
8
+ * ```ts
9
+ * const PRIVATE_KEY = secretFromString(process.env.SSH_CA_PRIVATE_KEY ?? "")
10
+ * ```
11
+ *
12
+ * @param value The CA private key in OpenSSH format
13
+ * @returns A secret for the `PRIVATE_KEY` binding, see {@link CaSecret}
14
+ */
15
+ export const secretFromString = (value: string): CaSecret => ({
16
+ get: async (): Promise<string> => value,
17
+ })
18
+
19
+ /**
20
+ * Use the contents of a file as the CA's private key secret.
21
+ *
22
+ * The file is read each time the key is needed, so replacing the file takes
23
+ * effect without a restart.
24
+ *
25
+ * @param path The path of a file containing the CA private key in OpenSSH format
26
+ * @returns A secret for the `PRIVATE_KEY` binding, see {@link CaSecret}
27
+ */
28
+ export const secretFromFile = (path: string): CaSecret => ({
29
+ get: async (): Promise<string> => await readFile(path, { encoding: "utf-8" }),
30
+ })
@@ -0,0 +1,315 @@
1
+ import type { DatabaseSync, SQLInputValue } from "node:sqlite"
2
+ import type { CaDatabase, CaDatabaseQueryResult, CaDatabaseResult, CaPreparedStatement } from "../types.js"
3
+
4
+ /**
5
+ * A single SQL statement split from a larger query by {@link splitStatements}.
6
+ */
7
+ export type SqlStatement = {
8
+ /** The SQL of the statement, without its trailing `;` */
9
+ sql: string
10
+ /** The number of anonymous (`?`) parameters in the statement */
11
+ parameters: number
12
+ /** Whether the statement uses numbered or named parameters (`?1`, `:name`, `@name` or `$name`) */
13
+ named: boolean
14
+ }
15
+
16
+ const isWordChar = (ch: string | undefined): boolean => ch !== undefined && /[A-Za-z0-9_$]/.test(ch)
17
+
18
+ /**
19
+ * Split SQL that may contain several statements into individual statements.
20
+ *
21
+ * `node:sqlite` only compiles the first statement passed to `prepare()`,
22
+ * whereas Cloudflare D1 runs them all, so queries are split here first. Quoted
23
+ * strings and identifiers, comments and `CREATE TRIGGER ... BEGIN ... END`
24
+ * bodies are not split. Statements containing nothing but whitespace or
25
+ * comments are dropped.
26
+ *
27
+ * @param sql The SQL to split
28
+ * @returns The statements found in `sql`, see {@link SqlStatement}
29
+ */
30
+ export const splitStatements = (sql: string): SqlStatement[] => {
31
+ const statements: SqlStatement[] = []
32
+
33
+ let start = 0
34
+ let parameters = 0
35
+ let named = false
36
+ let hasTokens = false
37
+ let words: string[] = []
38
+ let depth = 0
39
+
40
+ const push = (end: number): void => {
41
+ if (hasTokens) {
42
+ statements.push({ sql: sql.slice(start, end).trim(), parameters, named })
43
+ }
44
+ start = end + 1
45
+ parameters = 0
46
+ named = false
47
+ hasTokens = false
48
+ words = []
49
+ depth = 0
50
+ }
51
+
52
+ // true if the current statement is CREATE [TEMP|TEMPORARY] TRIGGER, whose body may contain ";"
53
+ const inTrigger = (): boolean => words[0] === "CREATE" && (words[1] === "TRIGGER" || words[2] === "TRIGGER")
54
+
55
+ let i = 0
56
+ while (i < sql.length) {
57
+ const ch = sql[i]
58
+ const next = sql[i + 1]
59
+
60
+ // comments
61
+ if (ch === "-" && next === "-") {
62
+ const end = sql.indexOf("\n", i)
63
+ i = end === -1 ? sql.length : end + 1
64
+ continue
65
+ }
66
+ if (ch === "/" && next === "*") {
67
+ const end = sql.indexOf("*/", i + 2)
68
+ i = end === -1 ? sql.length : end + 2
69
+ continue
70
+ }
71
+
72
+ // whitespace
73
+ if (ch === undefined || /\s/.test(ch)) {
74
+ i++
75
+ continue
76
+ }
77
+
78
+ // end of statement
79
+ if (ch === ";" && depth === 0) {
80
+ push(i)
81
+ i++
82
+ continue
83
+ }
84
+
85
+ hasTokens = true
86
+
87
+ // quoted strings and identifiers, where a doubled quote is an escaped quote
88
+ if (ch === "'" || ch === "\"" || ch === "`" || ch === "[") {
89
+ const close = ch === "[" ? "]" : ch
90
+ let j = i + 1
91
+ while (j < sql.length) {
92
+ if (sql[j] === close) {
93
+ if (close !== "]" && sql[j + 1] === close) {
94
+ j += 2
95
+ continue
96
+ }
97
+ break
98
+ }
99
+ j++
100
+ }
101
+ i = j + 1
102
+ continue
103
+ }
104
+
105
+ // parameters
106
+ if (ch === "?") {
107
+ let j = i + 1
108
+ while (j < sql.length && /[0-9]/.test(sql[j] ?? "")) {
109
+ j++
110
+ }
111
+ if (j === i + 1) {
112
+ parameters++
113
+ } else {
114
+ named = true
115
+ }
116
+ i = j
117
+ continue
118
+ }
119
+ if ((ch === ":" || ch === "@" || ch === "$") && isWordChar(next)) {
120
+ named = true
121
+ i++
122
+ while (isWordChar(sql[i])) {
123
+ i++
124
+ }
125
+ continue
126
+ }
127
+
128
+ // keywords and identifiers
129
+ if (isWordChar(ch)) {
130
+ let j = i
131
+ while (isWordChar(sql[j])) {
132
+ j++
133
+ }
134
+ const word = sql.slice(i, j).toUpperCase()
135
+ if (words.length < 3) {
136
+ words.push(word)
137
+ }
138
+ if (inTrigger()) {
139
+ if (word === "BEGIN" || word === "CASE") {
140
+ depth++
141
+ } else if (word === "END" && depth > 0) {
142
+ depth--
143
+ }
144
+ }
145
+ i = j
146
+ continue
147
+ }
148
+
149
+ i++
150
+ }
151
+
152
+ push(sql.length)
153
+
154
+ return statements
155
+ }
156
+
157
+ /**
158
+ * Convert a value bound to a statement into one `node:sqlite` accepts, using
159
+ * the same conversions as Cloudflare D1.
160
+ */
161
+ const toSqlValue = (value: unknown): SQLInputValue => {
162
+ if (value === null || typeof value === "number" || typeof value === "bigint" || typeof value === "string") {
163
+ return value
164
+ }
165
+ if (typeof value === "boolean") {
166
+ return value ? 1 : 0
167
+ }
168
+ if (value instanceof ArrayBuffer) {
169
+ return new Uint8Array(value)
170
+ }
171
+ if (ArrayBuffer.isView(value)) {
172
+ return new Uint8Array(value.buffer, value.byteOffset, value.byteLength)
173
+ }
174
+ throw new TypeError(`unsupported type for SQL parameter: ${value === undefined ? "undefined" : typeof value}`)
175
+ }
176
+
177
+ const toNumber = (value: unknown): number => {
178
+ if (typeof value === "number") {
179
+ return value
180
+ }
181
+ if (typeof value === "bigint") {
182
+ return Number(value)
183
+ }
184
+ throw new TypeError(`expected a number from SQLite but got ${typeof value}`)
185
+ }
186
+
187
+ // counters used to report D1 style metadata for any statement
188
+ const counters = (db: DatabaseSync): { changes: number, lastRowId: number } => {
189
+ const row = db.prepare("SELECT total_changes() AS changes, last_insert_rowid() AS last_row_id").get()
190
+ return { changes: toNumber(row?.changes), lastRowId: toNumber(row?.last_row_id) }
191
+ }
192
+
193
+ let savepointId = 0
194
+
195
+ // run fn atomically, using a savepoint so it also works inside an existing transaction
196
+ const atomically = <T>(db: DatabaseSync, fn: () => T): T => {
197
+ const name = `serverless_ssh_ca_${savepointId++}`
198
+ db.exec(`SAVEPOINT ${name}`)
199
+ try {
200
+ const result = fn()
201
+ db.exec(`RELEASE ${name}`)
202
+ return result
203
+ } catch (err) {
204
+ db.exec(`ROLLBACK TO ${name}`)
205
+ db.exec(`RELEASE ${name}`)
206
+ throw err
207
+ }
208
+ }
209
+
210
+ class NodeSqliteStatement implements CaPreparedStatement {
211
+ readonly db: DatabaseSync
212
+ private readonly sql: string
213
+ private readonly values: readonly SQLInputValue[]
214
+
215
+ constructor(db: DatabaseSync, sql: string, values: readonly SQLInputValue[] = []) {
216
+ this.db = db
217
+ this.sql = sql
218
+ this.values = values
219
+ }
220
+
221
+ bind(...values: unknown[]): NodeSqliteStatement {
222
+ return new NodeSqliteStatement(this.db, this.sql, values.map(toSqlValue))
223
+ }
224
+
225
+ async all(): Promise<CaDatabaseQueryResult> {
226
+ return this.execute()
227
+ }
228
+
229
+ async run(): Promise<CaDatabaseResult> {
230
+ const { success, meta } = this.execute()
231
+ return { success, meta }
232
+ }
233
+
234
+ // executes synchronously so no other query can run part way through
235
+ execute(): CaDatabaseQueryResult {
236
+ const started = performance.now()
237
+ const before = counters(this.db)
238
+ const results = this.query()
239
+ const after = counters(this.db)
240
+
241
+ return {
242
+ success: true,
243
+ results,
244
+ meta: {
245
+ changes: after.changes - before.changes,
246
+ last_row_id: after.lastRowId,
247
+ duration: performance.now() - started,
248
+ },
249
+ }
250
+ }
251
+
252
+ private query(): unknown[] {
253
+ const statements = splitStatements(this.sql)
254
+ if (statements.length <= 1) {
255
+ return this.db.prepare(this.sql).all(...this.values)
256
+ }
257
+
258
+ if (statements.some(s => s.named)) {
259
+ throw new Error("queries containing more than one statement only support anonymous (?) parameters")
260
+ }
261
+ const expected = statements.reduce((total, s) => total + s.parameters, 0)
262
+ if (expected !== this.values.length) {
263
+ throw new RangeError(`query expects ${expected} parameters but ${this.values.length} were bound`)
264
+ }
265
+
266
+ // give each statement its share of the bound values, returning the rows of the last statement
267
+ return atomically(this.db, () => {
268
+ let offset = 0
269
+ let results: unknown[] = []
270
+ for (const statement of statements) {
271
+ const values = this.values.slice(offset, offset + statement.parameters)
272
+ offset += statement.parameters
273
+ results = this.db.prepare(statement.sql).all(...values)
274
+ }
275
+ return results
276
+ })
277
+ }
278
+ }
279
+
280
+ /**
281
+ * Use a Node.js `node:sqlite` database as the CA's database.
282
+ *
283
+ * The returned object behaves like a Cloudflare D1 binding: queries containing
284
+ * several statements are run in full (see {@link splitStatements}) and
285
+ * {@link CaDatabase.batch} is atomic. The database's schema is created and
286
+ * migrated automatically when the CA first uses it.
287
+ *
288
+ * @example
289
+ * ```ts
290
+ * import { DatabaseSync } from "node:sqlite"
291
+ * import { fromNodeSqlite } from "@andrewheberle/serverless-ssh-ca/node"
292
+ *
293
+ * const DB = fromNodeSqlite(new DatabaseSync("ssh-ca.sqlite"))
294
+ * ```
295
+ *
296
+ * @param db An open `node:sqlite` database
297
+ * @returns A database for the `DB` binding, see {@link CaDatabase}
298
+ */
299
+ export const fromNodeSqlite = (db: DatabaseSync): CaDatabase => ({
300
+ prepare: (query: string): CaPreparedStatement => new NodeSqliteStatement(db, query),
301
+ batch: async (statements: CaPreparedStatement[]): Promise<CaDatabaseQueryResult[]> => {
302
+ const owned = statements.map(s => {
303
+ if (!(s instanceof NodeSqliteStatement) || s.db !== db) {
304
+ throw new TypeError("batch statements must be prepared by the same database")
305
+ }
306
+ return s
307
+ })
308
+ return atomically(db, () => owned.map(s => s.execute()))
309
+ },
310
+ exec: async (query: string): Promise<{ count: number, duration: number }> => {
311
+ const started = performance.now()
312
+ db.exec(query)
313
+ return { count: splitStatements(query).length, duration: performance.now() - started }
314
+ },
315
+ })
package/src/types.ts CHANGED
@@ -26,15 +26,61 @@ export type SSHExtension = {
26
26
  data: Buffer<ArrayBuffer>
27
27
  }
28
28
 
29
+ /**
30
+ * The result of executing a statement via {@link CaPreparedStatement.run} or
31
+ * {@link CaDatabase.batch}, modelled on a Cloudflare D1 result.
32
+ */
33
+ export interface CaDatabaseResult {
34
+ /** Whether the statement executed successfully */
35
+ success: boolean
36
+ meta?: {
37
+ /** The number of rows changed by the statement */
38
+ changes?: number
39
+ /** The rowid of the last row inserted */
40
+ last_row_id?: number
41
+ /** How long the statement took to execute in milliseconds */
42
+ duration?: number
43
+ }
44
+ }
45
+
46
+ /**
47
+ * The result of executing a statement that returns rows via
48
+ * {@link CaPreparedStatement.all}.
49
+ */
50
+ export interface CaDatabaseQueryResult extends CaDatabaseResult {
51
+ /** The rows returned, one object per row keyed by column name */
52
+ results: unknown[]
53
+ }
54
+
55
+ /**
56
+ * The subset of a Cloudflare D1 prepared statement used by the CA.
57
+ */
58
+ export interface CaPreparedStatement {
59
+ /** Bind positional (`?`) parameters, returning a statement to execute */
60
+ bind(...values: unknown[]): CaPreparedStatement
61
+ /** Execute the statement and return all rows */
62
+ all(): Promise<CaDatabaseQueryResult>
63
+ /** Execute the statement without returning rows */
64
+ run(): Promise<CaDatabaseResult>
65
+ }
66
+
29
67
  /**
30
68
  * The subset of a Cloudflare D1 database binding used by the CA.
31
69
  *
32
70
  * A Workers `D1Database` binding satisfies this structurally, so the package
33
- * does not need to depend on `@cloudflare/workers-types`.
71
+ * does not need to depend on `@cloudflare/workers-types`. Any other SQLite
72
+ * compatible database may be used by implementing this interface.
73
+ *
74
+ * The SQL the CA runs is SQLite dialect, and statements passed to
75
+ * {@link CaDatabase.prepare} may contain more than one SQL statement (as used
76
+ * when applying schema migrations).
34
77
  */
35
78
  export interface CaDatabase {
36
- prepare(query: string): unknown
37
- batch(statements: never[]): Promise<unknown>
79
+ /** Prepare a SQL query for execution, see {@link CaPreparedStatement} */
80
+ prepare(query: string): CaPreparedStatement
81
+ /** Execute several prepared statements atomically, returning a result for each */
82
+ batch(statements: CaPreparedStatement[]): Promise<CaDatabaseResult[]>
83
+ /** Execute one or more SQL statements without parameters */
38
84
  exec(query: string): Promise<unknown>
39
85
  }
40
86