Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Read db config from environment #125

Merged
merged 10 commits into from
Nov 9, 2023
76 changes: 24 additions & 52 deletions src/resolver.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,9 @@
import {createHmac} from "node:crypto";
import {readFile} from "node:fs/promises";
import {homedir} from "node:os";
import {join} from "node:path";
import {type CellPiece} from "./markdown.js";
import type {CellPiece} from "./markdown.js";

export type CellResolver = (cell: CellPiece) => CellPiece;

export interface ResolvedDatabaseReference {
name: string;
origin: string;
token: string;
type: string;
}
const DEFAULT_DATABASE_TOKEN_DURATION = 60 * 60 * 1000 * 36; // 36 hours in ms

interface DatabaseProxyItem {
secret: string;
}

type DatabaseProxyConfig = Record<string, DatabaseProxyItem>;

interface ObservableConfig {
"database-proxy": DatabaseProxyConfig;
}
export type CellResolver = (cell: CellPiece) => CellPiece;

interface DatabaseConfig {
host: string;
Expand All @@ -31,59 +13,49 @@ interface DatabaseConfig {
secret: string;
ssl: "disabled" | "enabled";
type: string;
url: string;
}

const configFile = join(homedir(), ".observablehq");
const key = `database-proxy`;

export async function readDatabaseProxyConfig(): Promise<DatabaseProxyConfig | null> {
let observableConfig;
try {
observableConfig = JSON.parse(await readFile(configFile, "utf-8")) as ObservableConfig | null;
} catch {
// Ignore missing config file
function getDatabaseProxyConfig(env: typeof process.env, name: string): DatabaseConfig | null {
const property = `OBSERVABLEHQ_DB_SECRET_${name}`;
if (env[property]) {
const config = JSON.parse(Buffer.from(env[property]!, "base64").toString("utf8")) as DatabaseConfig;
if (!config.host || !config.port || !config.secret) {
throw new Error(`Invalid database config: ${property}`);
}
return config;
}
return observableConfig && observableConfig[key];
}

function readDatabaseConfig(config: DatabaseProxyConfig | null, name): DatabaseConfig {
if (!config) throw new Error(`Missing database configuration file "${configFile}"`);
if (!name) throw new Error(`No database name specified`);
const raw = (config && config[name]) as DatabaseConfig | null;
if (!raw) throw new Error(`No configuration found for "${name}"`);
return {
...decodeSecret(raw.secret),
url: raw.url
} as DatabaseConfig;
return null;
}

function decodeSecret(secret: string): Record<string, string> {
return JSON.parse(Buffer.from(secret, "base64").toString("utf8"));
}

function encodeToken(payload: {name: string}, secret): string {
function encodeToken(payload: {name: string; exp: number}, secret: string): string {
const data = JSON.stringify(payload);
const hmac = createHmac("sha256", Buffer.from(secret, "hex")).update(data).digest();
return `${Buffer.from(data).toString("base64")}.${Buffer.from(hmac).toString("base64")}`;
}

export async function makeCLIResolver(): Promise<CellResolver> {
const config = await readDatabaseProxyConfig();
interface ResolverOptions {
databaseTokenDuration?: number;
env?: typeof process.env;
}

export async function makeCLIResolver({
databaseTokenDuration = DEFAULT_DATABASE_TOKEN_DURATION,
env = process.env
}: ResolverOptions = {}): Promise<CellResolver> {
return (cell: CellPiece): CellPiece => {
if (cell.databases !== undefined) {
cell = {
...cell,
databases: cell.databases.map((ref) => {
const db = readDatabaseConfig(config, ref.name);
const db = getDatabaseProxyConfig(env, ref.name);
if (db) {
const url = new URL("http://localhost");
url.protocol = db.ssl !== "disabled" ? "https:" : "http:";
url.host = db.host;
url.port = String(db.port);
return {
...ref,
token: encodeToken(ref, db.secret),
token: encodeToken({...ref, exp: Date.now() + databaseTokenDuration}, db.secret),
type: db.type,
url: url.toString()
};
Expand Down
45 changes: 45 additions & 0 deletions test/resolver-test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import assert from "node:assert";
import {makeCLIResolver} from "../src/resolver.js";

describe("resolver", () => {
it("resolves a database client", async () => {
const resolver = await makeCLIResolver({
env: {
OBSERVABLEHQ_DB_SECRET_snow2:
"eyJuYW1lIjoic25vdzIiLCJ0eXBlIjoic25vd2ZsYWtlIiwiaG9zdCI6ImxvY2FsaG9zdCIsInBvcnQiOiIyODk5Iiwic3NsIjoiZGlzYWJsZWQiLCJvcmlnaW4iOiJodHRwOi8vMTI3LjAuMC4xOjMwMDAiLCJzZWNyZXQiOiJhNzQyOGJjMWY1ZjhhYzlkYTgzZDQ1ZGFlNTMwMDA5NjczY2U5MTRkY2ZmZDM0ZDA5ZTM2ZmUwY2I2M2Y4ZTMxIn0="
}
});
const cell = resolver({
type: "cell",
id: "",
body: "",
databases: [{name: "snow2"}]
});
assert.equal(cell.databases?.length, 1);
const database = cell.databases![0];
const token = (database as any).token;
const tokenDecoded = JSON.parse(Buffer.from(token.split(".")[0], "base64").toString("utf8"));
assert.equal(tokenDecoded.name, "snow2");
assert.deepStrictEqual(database, {name: "snow2", type: "snowflake", url: "http://localhost:2899/", token});
});

it("throws an error when it can't resolve", async () => {
const resolver = await makeCLIResolver({
env: {
OBSERVABLEHQ_DB_SECRET_snow2:
"eyJuYW1lIjoic25vdzIiLCJ0eXBlIjoic25vd2ZsYWtlIiwiaG9zdCI6ImxvY2FsaG9zdCIsInBvcnQiOiIyODk5Iiwic3NsIjoiZGlzYWJsZWQiLCJvcmlnaW4iOiJodHRwOi8vMTI3LjAuMC4xOjMwMDAiLCJzZWNyZXQiOiJhNzQyOGJjMWY1ZjhhYzlkYTgzZDQ1ZGFlNTMwMDA5NjczY2U5MTRkY2ZmZDM0ZDA5ZTM2ZmUwY2I2M2Y4ZTMxIn0="
}
});
try {
resolver({
type: "cell",
id: "",
body: "",
databases: [{name: "notthere"}]
});
assert.fail("should have thrown");
} catch (error: any) {
assert.equal(error.message, 'Unable to resolve database "notthere"');
}
});
});