Skip to content

Commit 48c20d1

Browse files
Read db config from environment (#125)
* Read db config from environment * Updates and tests * Fix test case * Simplify further
1 parent 63f0d42 commit 48c20d1

File tree

2 files changed

+69
-52
lines changed

2 files changed

+69
-52
lines changed

src/resolver.ts

Lines changed: 24 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,9 @@
11
import {createHmac} from "node:crypto";
2-
import {readFile} from "node:fs/promises";
3-
import {homedir} from "node:os";
4-
import {join} from "node:path";
5-
import {type CellPiece} from "./markdown.js";
2+
import type {CellPiece} from "./markdown.js";
63

7-
export type CellResolver = (cell: CellPiece) => CellPiece;
8-
9-
export interface ResolvedDatabaseReference {
10-
name: string;
11-
origin: string;
12-
token: string;
13-
type: string;
14-
}
4+
const DEFAULT_DATABASE_TOKEN_DURATION = 60 * 60 * 1000 * 36; // 36 hours in ms
155

16-
interface DatabaseProxyItem {
17-
secret: string;
18-
}
19-
20-
type DatabaseProxyConfig = Record<string, DatabaseProxyItem>;
21-
22-
interface ObservableConfig {
23-
"database-proxy": DatabaseProxyConfig;
24-
}
6+
export type CellResolver = (cell: CellPiece) => CellPiece;
257

268
interface DatabaseConfig {
279
host: string;
@@ -31,59 +13,49 @@ interface DatabaseConfig {
3113
secret: string;
3214
ssl: "disabled" | "enabled";
3315
type: string;
34-
url: string;
3516
}
3617

37-
const configFile = join(homedir(), ".observablehq");
38-
const key = `database-proxy`;
39-
40-
export async function readDatabaseProxyConfig(): Promise<DatabaseProxyConfig | null> {
41-
let observableConfig;
42-
try {
43-
observableConfig = JSON.parse(await readFile(configFile, "utf-8")) as ObservableConfig | null;
44-
} catch {
45-
// Ignore missing config file
18+
function getDatabaseProxyConfig(env: typeof process.env, name: string): DatabaseConfig | null {
19+
const property = `OBSERVABLEHQ_DB_SECRET_${name}`;
20+
if (env[property]) {
21+
const config = JSON.parse(Buffer.from(env[property]!, "base64").toString("utf8")) as DatabaseConfig;
22+
if (!config.host || !config.port || !config.secret) {
23+
throw new Error(`Invalid database config: ${property}`);
24+
}
25+
return config;
4626
}
47-
return observableConfig && observableConfig[key];
48-
}
49-
50-
function readDatabaseConfig(config: DatabaseProxyConfig | null, name): DatabaseConfig {
51-
if (!config) throw new Error(`Missing database configuration file "${configFile}"`);
52-
if (!name) throw new Error(`No database name specified`);
53-
const raw = (config && config[name]) as DatabaseConfig | null;
54-
if (!raw) throw new Error(`No configuration found for "${name}"`);
55-
return {
56-
...decodeSecret(raw.secret),
57-
url: raw.url
58-
} as DatabaseConfig;
27+
return null;
5928
}
6029

61-
function decodeSecret(secret: string): Record<string, string> {
62-
return JSON.parse(Buffer.from(secret, "base64").toString("utf8"));
63-
}
64-
65-
function encodeToken(payload: {name: string}, secret): string {
30+
function encodeToken(payload: {name: string; exp: number}, secret: string): string {
6631
const data = JSON.stringify(payload);
6732
const hmac = createHmac("sha256", Buffer.from(secret, "hex")).update(data).digest();
6833
return `${Buffer.from(data).toString("base64")}.${Buffer.from(hmac).toString("base64")}`;
6934
}
7035

71-
export async function makeCLIResolver(): Promise<CellResolver> {
72-
const config = await readDatabaseProxyConfig();
36+
interface ResolverOptions {
37+
databaseTokenDuration?: number;
38+
env?: typeof process.env;
39+
}
40+
41+
export async function makeCLIResolver({
42+
databaseTokenDuration = DEFAULT_DATABASE_TOKEN_DURATION,
43+
env = process.env
44+
}: ResolverOptions = {}): Promise<CellResolver> {
7345
return (cell: CellPiece): CellPiece => {
7446
if (cell.databases !== undefined) {
7547
cell = {
7648
...cell,
7749
databases: cell.databases.map((ref) => {
78-
const db = readDatabaseConfig(config, ref.name);
50+
const db = getDatabaseProxyConfig(env, ref.name);
7951
if (db) {
8052
const url = new URL("http://localhost");
8153
url.protocol = db.ssl !== "disabled" ? "https:" : "http:";
8254
url.host = db.host;
8355
url.port = String(db.port);
8456
return {
8557
...ref,
86-
token: encodeToken(ref, db.secret),
58+
token: encodeToken({...ref, exp: Date.now() + databaseTokenDuration}, db.secret),
8759
type: db.type,
8860
url: url.toString()
8961
};

test/resolver-test.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import assert from "node:assert";
2+
import {makeCLIResolver} from "../src/resolver.js";
3+
4+
describe("resolver", () => {
5+
it("resolves a database client", async () => {
6+
const resolver = await makeCLIResolver({
7+
env: {
8+
OBSERVABLEHQ_DB_SECRET_snow2:
9+
"eyJuYW1lIjoic25vdzIiLCJ0eXBlIjoic25vd2ZsYWtlIiwiaG9zdCI6ImxvY2FsaG9zdCIsInBvcnQiOiIyODk5Iiwic3NsIjoiZGlzYWJsZWQiLCJvcmlnaW4iOiJodHRwOi8vMTI3LjAuMC4xOjMwMDAiLCJzZWNyZXQiOiJhNzQyOGJjMWY1ZjhhYzlkYTgzZDQ1ZGFlNTMwMDA5NjczY2U5MTRkY2ZmZDM0ZDA5ZTM2ZmUwY2I2M2Y4ZTMxIn0="
10+
}
11+
});
12+
const cell = resolver({
13+
type: "cell",
14+
id: "",
15+
body: "",
16+
databases: [{name: "snow2"}]
17+
});
18+
assert.equal(cell.databases?.length, 1);
19+
const database = cell.databases![0];
20+
const token = (database as any).token;
21+
const tokenDecoded = JSON.parse(Buffer.from(token.split(".")[0], "base64").toString("utf8"));
22+
assert.equal(tokenDecoded.name, "snow2");
23+
assert.deepStrictEqual(database, {name: "snow2", type: "snowflake", url: "http://localhost:2899/", token});
24+
});
25+
26+
it("throws an error when it can't resolve", async () => {
27+
const resolver = await makeCLIResolver({
28+
env: {
29+
OBSERVABLEHQ_DB_SECRET_snow2:
30+
"eyJuYW1lIjoic25vdzIiLCJ0eXBlIjoic25vd2ZsYWtlIiwiaG9zdCI6ImxvY2FsaG9zdCIsInBvcnQiOiIyODk5Iiwic3NsIjoiZGlzYWJsZWQiLCJvcmlnaW4iOiJodHRwOi8vMTI3LjAuMC4xOjMwMDAiLCJzZWNyZXQiOiJhNzQyOGJjMWY1ZjhhYzlkYTgzZDQ1ZGFlNTMwMDA5NjczY2U5MTRkY2ZmZDM0ZDA5ZTM2ZmUwY2I2M2Y4ZTMxIn0="
31+
}
32+
});
33+
try {
34+
resolver({
35+
type: "cell",
36+
id: "",
37+
body: "",
38+
databases: [{name: "notthere"}]
39+
});
40+
assert.fail("should have thrown");
41+
} catch (error: any) {
42+
assert.equal(error.message, 'Unable to resolve database "notthere"');
43+
}
44+
});
45+
});

0 commit comments

Comments
 (0)