@devosurf/tesser-server 0.1.0-alpha.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.
Files changed (43) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +18 -0
  3. package/bin/tesser-server.mjs +2 -0
  4. package/dist/main.js +6296 -0
  5. package/dist/main.js.map +7 -0
  6. package/package.json +42 -0
  7. package/src/broker/broker.ts +332 -0
  8. package/src/broker/connect.ts +224 -0
  9. package/src/broker/connections.ts +278 -0
  10. package/src/broker/crypto.ts +39 -0
  11. package/src/broker/masking.ts +32 -0
  12. package/src/broker/oauth.ts +170 -0
  13. package/src/config.ts +128 -0
  14. package/src/db/db.ts +114 -0
  15. package/src/db/migrate.ts +35 -0
  16. package/src/db/migrations.ts +302 -0
  17. package/src/engine/executor.ts +536 -0
  18. package/src/engine/runs.ts +83 -0
  19. package/src/engine/signals.ts +18 -0
  20. package/src/engine/types.ts +53 -0
  21. package/src/events/fanout.ts +73 -0
  22. package/src/gitsync/build.ts +102 -0
  23. package/src/gitsync/deploy-keys.ts +59 -0
  24. package/src/gitsync/reconciler.ts +429 -0
  25. package/src/http/api.ts +425 -0
  26. package/src/http/app.ts +33 -0
  27. package/src/http/connect-view.ts +290 -0
  28. package/src/http/connect.ts +351 -0
  29. package/src/http/ingress.ts +204 -0
  30. package/src/http/status.ts +171 -0
  31. package/src/http/tokens.ts +46 -0
  32. package/src/index.ts +20 -0
  33. package/src/main.ts +26 -0
  34. package/src/queue/queue.ts +133 -0
  35. package/src/queue/worker.ts +85 -0
  36. package/src/registry/loader.ts +41 -0
  37. package/src/scheduler/cron.ts +115 -0
  38. package/src/scheduler/reaper.ts +105 -0
  39. package/src/server.ts +162 -0
  40. package/src/triggers/ingress.ts +154 -0
  41. package/src/triggers/poll.ts +167 -0
  42. package/src/triggers/registrar.ts +274 -0
  43. package/src/triggers/shared.ts +188 -0
@@ -0,0 +1,278 @@
1
+ // Wires the broker into the engine: builds ctx.connections (typed clients whose every
2
+ // call gets the credential injected at call time) and ctx.secrets. The automation never
3
+ // sees a value that isn't masked in logs.
4
+
5
+ import { TerminalError, type AutomationDef, type HarnessRunResult, type Logger, type NormalizedModelResponse } from "@devosurf/tesser-sdk";
6
+ import type {
7
+ ActionCtx,
8
+ AnyAction,
9
+ AuthDecl,
10
+ ConnectorInstance,
11
+ OAuth2ProviderFacts,
12
+ ProviderFacts,
13
+ } from "@devosurf/tesser-sdk/connector";
14
+ import {
15
+ buildConnectorClient,
16
+ createHttpClient,
17
+ isRetrySafe,
18
+ runAction,
19
+ type HarnessCallInfo,
20
+ type ModelCallInfo,
21
+ type TesserHttpConfig,
22
+ } from "@devosurf/tesser-sdk/internal";
23
+ import type { Db } from "../db/db.js";
24
+ import type { ActiveStepHooks, EngineDeps, RunRow } from "../engine/types.js";
25
+ import type { Broker, ConnectionRow } from "./broker.js";
26
+
27
+ export function authDeclFor(spec: ConnectorInstance<any, any>["__connector"], mode: string): AuthDecl {
28
+ const auth = spec.auth as AuthDecl | Record<string, AuthDecl>;
29
+ if ("kind" in auth && typeof auth.kind === "string") return auth as AuthDecl;
30
+ const decl = (auth as Record<string, AuthDecl>)[mode];
31
+ if (!decl) throw new TerminalError(`connector ${spec.id}: unknown auth mode "${mode}"`);
32
+ return decl;
33
+ }
34
+
35
+ export function providerFactsOf(spec: ConnectorInstance<any, any>["__connector"]): ProviderFacts | undefined {
36
+ return typeof spec.provider === "object" ? spec.provider : undefined;
37
+ }
38
+
39
+ export function applyAuthFor(decl: AuthDecl, fields: Record<string, string>): TesserHttpConfig["applyAuth"] {
40
+ switch (decl.kind) {
41
+ case "oauth2":
42
+ return ({ headers }) => {
43
+ headers.set("authorization", `Bearer ${fields["access_token"] ?? ""}`);
44
+ };
45
+ case "apiKey": {
46
+ const value = (decl.prefix ?? "") + (fields["api_key"] ?? "");
47
+ if (decl.in === "query") {
48
+ return ({ url }) => {
49
+ url.searchParams.set(decl.name, fields["api_key"] ?? "");
50
+ };
51
+ }
52
+ return ({ headers }) => {
53
+ headers.set(decl.name, value);
54
+ };
55
+ }
56
+ case "basic":
57
+ return ({ headers }) => {
58
+ headers.set(
59
+ "authorization",
60
+ `Basic ${Buffer.from(`${fields["username"] ?? ""}:${fields["password"] ?? ""}`).toString("base64")}`,
61
+ );
62
+ };
63
+ case "custom":
64
+ return (req) => decl.sign(req, fields);
65
+ }
66
+ }
67
+
68
+ export interface BindingDeps {
69
+ db: Db;
70
+ broker: Broker;
71
+ fetchImpl?: typeof fetch;
72
+ }
73
+
74
+ async function workspaceOf(db: Db, projectId: string): Promise<string> {
75
+ const { rows } = await db.query<{ workspace_id: string }>(
76
+ `SELECT workspace_id FROM projects WHERE id=$1`,
77
+ [projectId],
78
+ );
79
+ if (!rows[0]) throw new TerminalError(`project ${projectId} not found`);
80
+ return rows[0].workspace_id;
81
+ }
82
+
83
+ export function makeEngineBindings(deps: BindingDeps): Pick<EngineDeps, "buildConnections" | "resolveSecrets" | "callModel" | "callHarness"> {
84
+ async function connectionFor(run: RunRow, def: AutomationDef<any, any, any, any, any, any, any>, reqKey: string) {
85
+ const connector = ((def.connections ?? {}) as Record<string, ConnectorInstance<any, any>>)[reqKey];
86
+ if (!connector) throw new TerminalError(`connection "${reqKey}" is not declared`);
87
+ const workspaceId = await workspaceOf(deps.db, run.project_id);
88
+ const endUserId = (run.trigger["endUserId"] as string | undefined) ?? undefined;
89
+ const conn = await deps.broker.resolveBinding({
90
+ workspaceId,
91
+ projectId: run.project_id,
92
+ automationId: run.automation_id,
93
+ env: run.env,
94
+ reqKey,
95
+ connectorId: connector.id,
96
+ scope: connector.scope ?? "workspace",
97
+ endUserId,
98
+ });
99
+ if (!conn) {
100
+ throw new TerminalError(
101
+ `no ready ${connector.id} connection for "${reqKey}" — run \`tesser connect\` (deploy should have halted; ADR-0005)`,
102
+ );
103
+ }
104
+ return { workspaceId, connector, conn };
105
+ }
106
+
107
+ return {
108
+ async buildConnections(run: RunRow, def: AutomationDef<any, any, any, any, any, any, any>, hooks: ActiveStepHooks) {
109
+ const out: Record<string, unknown> = {};
110
+ const entries = Object.entries((def.connections ?? {}) as Record<string, ConnectorInstance<any, any>>);
111
+ if (entries.length === 0) return out;
112
+ const workspaceId = await workspaceOf(deps.db, run.project_id);
113
+
114
+ for (const [reqKey, connector] of entries) {
115
+ const spec = connector.__connector;
116
+ const facts = providerFactsOf(spec);
117
+ const endUserId = (run.trigger["endUserId"] as string | undefined) ?? undefined;
118
+
119
+ out[reqKey] = buildConnectorClient(connector, async (path, actionDef, input) => {
120
+ const step = hooks.current();
121
+ if (!step) {
122
+ throw new TerminalError(
123
+ `connector call ${connector.id}.${path.join(".")} outside ctx.step — side effects live inside steps (ADR-0002)`,
124
+ );
125
+ }
126
+ const { conn } = await connectionFor(run, def, reqKey);
127
+ if (!isRetrySafe(actionDef as AnyAction, spec.idempotencyHeader !== undefined)) {
128
+ step.markUnsafeWrite();
129
+ }
130
+ const decl = authDeclFor(spec, conn.auth_mode);
131
+ const bundle = await deps.broker.freshCredential(conn.id, facts?.oauth2);
132
+
133
+ const actx = makeActionCtx({
134
+ spec,
135
+ decl,
136
+ conn,
137
+ facts,
138
+ fields: bundle.fields,
139
+ broker: deps.broker,
140
+ idempotencyKey: step.idempotencyKey,
141
+ ...(deps.fetchImpl !== undefined ? { fetchImpl: deps.fetchImpl } : {}),
142
+ ...(actionDef.classifyError !== undefined ? { classifyError: actionDef.classifyError } : {}),
143
+ });
144
+ return runAction(actionDef as AnyAction, actx, input, `${connector.id}.${path.join(".")}`);
145
+ });
146
+ }
147
+ return out;
148
+ },
149
+
150
+ async resolveSecrets(run: RunRow, def: AutomationDef<any, any, any, any, any, any, any>) {
151
+ const names = Object.keys((def.secrets ?? {}) as Record<string, unknown>);
152
+ if (names.length === 0) return {};
153
+ const workspaceId = await workspaceOf(deps.db, run.project_id);
154
+ const out: Record<string, string> = {};
155
+ for (const name of names) {
156
+ const value = await deps.broker.getSecretValue(workspaceId, name);
157
+ if (value === null) {
158
+ throw new TerminalError(`secret "${name}" is not set — deploy should have halted (ADR-0005)`);
159
+ }
160
+ out[name] = value;
161
+ }
162
+ return out;
163
+ },
164
+
165
+ async callModel(
166
+ run: RunRow,
167
+ def: AutomationDef<any, any, any, any, any, any, any>,
168
+ hooks: ActiveStepHooks,
169
+ info: ModelCallInfo,
170
+ ): Promise<NormalizedModelResponse> {
171
+ const step = hooks.current();
172
+ if (!step) {
173
+ throw new TerminalError(`model call ${info.operatorKey}.${info.modelKey} outside ctx.step`);
174
+ }
175
+ const { connector, conn } = await connectionFor(run, def, info.model.connection);
176
+ const spec = connector.__connector;
177
+ if (!spec.modelProvider) {
178
+ throw new TerminalError(`connection "${info.model.connection}" (${connector.id}) is not model-capable`);
179
+ }
180
+ const facts = providerFactsOf(spec);
181
+ const decl = authDeclFor(spec, conn.auth_mode);
182
+ const bundle = await deps.broker.freshCredential(conn.id, facts?.oauth2);
183
+ const actx = makeActionCtx({
184
+ spec,
185
+ decl,
186
+ conn,
187
+ facts,
188
+ fields: bundle.fields,
189
+ broker: deps.broker,
190
+ idempotencyKey: step.idempotencyKey,
191
+ ...(deps.fetchImpl !== undefined ? { fetchImpl: deps.fetchImpl } : {}),
192
+ });
193
+ return spec.modelProvider.call(actx, info.request);
194
+ },
195
+
196
+ async callHarness(
197
+ run: RunRow,
198
+ def: AutomationDef<any, any, any, any, any, any, any>,
199
+ hooks: ActiveStepHooks,
200
+ info: HarnessCallInfo,
201
+ ): Promise<HarnessRunResult<unknown>> {
202
+ const step = hooks.current();
203
+ if (!step) throw new TerminalError(`harness.${info.harnessKey} ran outside ctx.step`);
204
+ const { connector, conn } = await connectionFor(run, def, info.harness.connection);
205
+ const spec = connector.__connector;
206
+ if (!spec.harnessProvider) {
207
+ throw new TerminalError(`connection "${info.harness.connection}" (${connector.id}) is not harness-capable`);
208
+ }
209
+ const facts = providerFactsOf(spec);
210
+ const decl = authDeclFor(spec, conn.auth_mode);
211
+ const bundle = await deps.broker.freshCredential(conn.id, facts?.oauth2);
212
+ const actx = makeActionCtx({
213
+ spec,
214
+ decl,
215
+ conn,
216
+ facts,
217
+ fields: bundle.fields,
218
+ broker: deps.broker,
219
+ idempotencyKey: step.idempotencyKey,
220
+ ...(deps.fetchImpl !== undefined ? { fetchImpl: deps.fetchImpl } : {}),
221
+ });
222
+ return spec.harnessProvider.run(actx, info.request, info.harness);
223
+ },
224
+ };
225
+ }
226
+
227
+ /** Build a pre-authed ActionCtx for one connector call — also used by the trigger
228
+ * registrar and pollers (ADR-0013 shares the broker boundary). */
229
+ export function makeActionCtx(opts: {
230
+ spec: ConnectorInstance<any, any>["__connector"];
231
+ decl: AuthDecl;
232
+ conn: ConnectionRow;
233
+ facts: ProviderFacts | undefined;
234
+ fields: Record<string, string>;
235
+ broker: Broker;
236
+ idempotencyKey?: string | undefined;
237
+ fetchImpl?: typeof fetch | undefined;
238
+ classifyError?: AnyAction["classifyError"] | undefined;
239
+ logger?: Logger;
240
+ }): ActionCtx {
241
+ const baseUrl = opts.spec.baseUrl ?? opts.facts?.baseUrl;
242
+ const oauthFacts: OAuth2ProviderFacts | undefined = opts.facts?.oauth2;
243
+ let fields = opts.fields;
244
+
245
+ const http = createHttpClient({
246
+ ...(baseUrl !== undefined ? { baseUrl } : {}),
247
+ ...(opts.spec.defaultHeaders !== undefined ? { defaultHeaders: opts.spec.defaultHeaders } : {}),
248
+ ...(opts.fetchImpl !== undefined ? { fetchImpl: opts.fetchImpl } : {}),
249
+ ...(opts.classifyError !== undefined ? { classifyError: opts.classifyError } : {}),
250
+ applyAuth: async (req) => {
251
+ const apply = applyAuthFor(opts.decl, fields);
252
+ await apply?.(req);
253
+ },
254
+ ...(opts.decl.kind === "oauth2" && oauthFacts !== undefined
255
+ ? {
256
+ onUnauthorized: async () => {
257
+ const refreshed = await opts.broker.refreshConnection(opts.conn.id, oauthFacts);
258
+ if (refreshed) {
259
+ fields = (await opts.broker.getCredential(opts.conn.id)).fields;
260
+ }
261
+ return refreshed;
262
+ },
263
+ }
264
+ : {}),
265
+ });
266
+
267
+ const silent: Logger = { info() {}, warn() {}, error() {} };
268
+ return {
269
+ http,
270
+ auth: {
271
+ kind: opts.decl.kind,
272
+ ...(opts.conn.auth_mode !== "default" ? { mode: opts.conn.auth_mode } : {}),
273
+ fields,
274
+ },
275
+ logger: opts.logger ?? silent,
276
+ ...(opts.idempotencyKey !== undefined ? { idempotencyKey: opts.idempotencyKey } : {}),
277
+ };
278
+ }
@@ -0,0 +1,39 @@
1
+ // Envelope encryption (ADR-0005): master key (env/file) wraps per-workspace data keys;
2
+ // data keys encrypt credential/secret values at rest. AES-256-GCM, versioned format.
3
+
4
+ import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
5
+
6
+ const VERSION = "v1";
7
+
8
+ export function encrypt(key: Buffer, plaintext: string | Buffer): string {
9
+ if (key.length !== 32) throw new Error("encryption key must be 32 bytes");
10
+ const iv = randomBytes(12);
11
+ const cipher = createCipheriv("aes-256-gcm", key, iv);
12
+ const data = Buffer.concat([cipher.update(plaintext), cipher.final()]);
13
+ const tag = cipher.getAuthTag();
14
+ return [VERSION, iv.toString("base64"), tag.toString("base64"), data.toString("base64")].join(":");
15
+ }
16
+
17
+ export function decrypt(key: Buffer, payload: string): Buffer {
18
+ const [version, ivB64, tagB64, dataB64] = payload.split(":");
19
+ if (version !== VERSION || !ivB64 || !tagB64 || !dataB64) {
20
+ throw new Error("unrecognized ciphertext format");
21
+ }
22
+ const decipher = createDecipheriv("aes-256-gcm", key, Buffer.from(ivB64, "base64"));
23
+ decipher.setAuthTag(Buffer.from(tagB64, "base64"));
24
+ return Buffer.concat([decipher.update(Buffer.from(dataB64, "base64")), decipher.final()]);
25
+ }
26
+
27
+ export function generateDataKey(): Buffer {
28
+ return randomBytes(32);
29
+ }
30
+
31
+ export function wrapDataKey(masterKey: Buffer, dataKey: Buffer): string {
32
+ return encrypt(masterKey, dataKey);
33
+ }
34
+
35
+ export function unwrapDataKey(masterKey: Buffer, wrapped: string): Buffer {
36
+ const key = decrypt(masterKey, wrapped);
37
+ if (key.length !== 32) throw new Error("unwrapped data key has wrong length");
38
+ return key;
39
+ }
@@ -0,0 +1,32 @@
1
+ // Log masking (ADR-0005): every decrypted credential value (≥ 8 chars — shorter values
2
+ // would mask common words) is registered here; every string headed to logs passes
3
+ // through mask(). The broker is the only place values are decrypted, so registration
4
+ // is structurally complete.
5
+
6
+ const MIN_LENGTH = 8;
7
+
8
+ export class Masker {
9
+ private values = new Map<string, string>(); // value → label
10
+
11
+ add(value: string, label = "secret"): void {
12
+ if (typeof value !== "string" || value.length < MIN_LENGTH) return;
13
+ this.values.set(value, label);
14
+ }
15
+
16
+ addFields(fields: Record<string, string>, prefix: string): void {
17
+ for (const [k, v] of Object.entries(fields)) this.add(v, `${prefix}.${k}`);
18
+ }
19
+
20
+ mask(text: string): string {
21
+ if (typeof text !== "string" || text.length === 0) return text;
22
+ let out = text;
23
+ for (const [value, label] of this.values) {
24
+ if (out.includes(value)) out = out.split(value).join(`[masked:${label}]`);
25
+ }
26
+ return out;
27
+ }
28
+
29
+ get size(): number {
30
+ return this.values.size;
31
+ }
32
+ }
@@ -0,0 +1,170 @@
1
+ // Our own OAuth2 executor (ADR-0004): auth-code + PKCE, refresh, client-credentials —
2
+ // driven entirely by Catalog provider facts. No third-party OAuth code ships.
3
+
4
+ import { createHash, randomBytes } from "node:crypto";
5
+ import type { OAuth2ProviderFacts } from "@devosurf/tesser-sdk/connector";
6
+
7
+ export interface TokenSet {
8
+ accessToken: string;
9
+ refreshToken?: string;
10
+ /** Epoch ms; undefined = non-expiring (e.g. GitHub OAuth-app tokens). */
11
+ expiresAt?: number;
12
+ tokenType?: string;
13
+ scope?: string;
14
+ raw: Record<string, unknown>;
15
+ }
16
+
17
+ export class OAuthError extends Error {
18
+ constructor(
19
+ message: string,
20
+ readonly providerResponse?: unknown,
21
+ ) {
22
+ super(message);
23
+ this.name = "OAuthError";
24
+ }
25
+ }
26
+
27
+ function b64url(buf: Buffer): string {
28
+ return buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
29
+ }
30
+
31
+ export function generatePkce(): { verifier: string; challenge: string } {
32
+ const verifier = b64url(randomBytes(48));
33
+ const challenge = b64url(createHash("sha256").update(verifier).digest());
34
+ return { verifier, challenge };
35
+ }
36
+
37
+ export function buildAuthorizeUrl(opts: {
38
+ facts: OAuth2ProviderFacts;
39
+ clientId: string;
40
+ redirectUri: string;
41
+ scopes: readonly string[];
42
+ state: string;
43
+ codeChallenge?: string | undefined;
44
+ }): string {
45
+ const url = new URL(opts.facts.authorizeUrl);
46
+ url.searchParams.set("client_id", opts.clientId);
47
+ url.searchParams.set("redirect_uri", opts.redirectUri);
48
+ url.searchParams.set("response_type", "code");
49
+ url.searchParams.set("state", opts.state);
50
+ if (opts.scopes.length > 0) {
51
+ url.searchParams.set("scope", opts.scopes.join(opts.facts.scopeSeparator ?? " "));
52
+ }
53
+ if (opts.codeChallenge !== undefined) {
54
+ url.searchParams.set("code_challenge", opts.codeChallenge);
55
+ url.searchParams.set("code_challenge_method", "S256");
56
+ }
57
+ for (const [k, v] of Object.entries(opts.facts.extraAuthorizeParams ?? {})) {
58
+ url.searchParams.set(k, v);
59
+ }
60
+ return url.toString();
61
+ }
62
+
63
+ async function tokenRequest(
64
+ facts: OAuth2ProviderFacts,
65
+ clientId: string,
66
+ clientSecret: string | undefined,
67
+ params: Record<string, string>,
68
+ fetchImpl: typeof fetch,
69
+ ): Promise<TokenSet> {
70
+ const body = new URLSearchParams(params);
71
+ const headers = new Headers({
72
+ "content-type": "application/x-www-form-urlencoded",
73
+ // GitHub answers form-encoded unless asked for JSON.
74
+ accept: "application/json",
75
+ });
76
+ if ((facts.clientAuth ?? "body") === "basic") {
77
+ headers.set("authorization", `Basic ${Buffer.from(`${clientId}:${clientSecret ?? ""}`).toString("base64")}`);
78
+ } else {
79
+ body.set("client_id", clientId);
80
+ if (clientSecret !== undefined) body.set("client_secret", clientSecret);
81
+ }
82
+ for (const [k, v] of Object.entries(facts.extraTokenParams ?? {})) body.set(k, v);
83
+
84
+ let res: Response;
85
+ try {
86
+ res = await fetchImpl(facts.tokenUrl, { method: "POST", headers, body });
87
+ } catch (cause) {
88
+ throw new OAuthError(`token endpoint unreachable: ${String(cause)}`);
89
+ }
90
+ const text = await res.text();
91
+ let parsed: Record<string, unknown>;
92
+ try {
93
+ parsed = JSON.parse(text) as Record<string, unknown>;
94
+ } catch {
95
+ parsed = Object.fromEntries(new URLSearchParams(text)); // form-encoded fallback
96
+ }
97
+ if (!res.ok || parsed["error"] !== undefined || typeof parsed["access_token"] !== "string") {
98
+ throw new OAuthError(
99
+ `token endpoint ${res.status}: ${String(parsed["error"] ?? text.slice(0, 200))}`,
100
+ parsed,
101
+ );
102
+ }
103
+ const expiresIn = Number(parsed["expires_in"]);
104
+ return {
105
+ accessToken: parsed["access_token"] as string,
106
+ ...(typeof parsed["refresh_token"] === "string" ? { refreshToken: parsed["refresh_token"] } : {}),
107
+ ...(Number.isFinite(expiresIn) && expiresIn > 0 ? { expiresAt: Date.now() + expiresIn * 1000 } : {}),
108
+ ...(typeof parsed["token_type"] === "string" ? { tokenType: parsed["token_type"] } : {}),
109
+ ...(typeof parsed["scope"] === "string" ? { scope: parsed["scope"] } : {}),
110
+ raw: parsed,
111
+ };
112
+ }
113
+
114
+ export async function exchangeCode(opts: {
115
+ facts: OAuth2ProviderFacts;
116
+ clientId: string;
117
+ clientSecret?: string | undefined;
118
+ code: string;
119
+ redirectUri: string;
120
+ codeVerifier?: string | undefined;
121
+ fetchImpl?: typeof fetch;
122
+ }): Promise<TokenSet> {
123
+ return tokenRequest(
124
+ opts.facts,
125
+ opts.clientId,
126
+ opts.clientSecret,
127
+ {
128
+ grant_type: "authorization_code",
129
+ code: opts.code,
130
+ redirect_uri: opts.redirectUri,
131
+ ...(opts.codeVerifier !== undefined ? { code_verifier: opts.codeVerifier } : {}),
132
+ },
133
+ opts.fetchImpl ?? fetch,
134
+ );
135
+ }
136
+
137
+ export async function refreshToken(opts: {
138
+ facts: OAuth2ProviderFacts;
139
+ clientId: string;
140
+ clientSecret?: string | undefined;
141
+ refreshToken: string;
142
+ fetchImpl?: typeof fetch;
143
+ }): Promise<TokenSet> {
144
+ return tokenRequest(
145
+ opts.facts,
146
+ opts.clientId,
147
+ opts.clientSecret,
148
+ { grant_type: "refresh_token", refresh_token: opts.refreshToken },
149
+ opts.fetchImpl ?? fetch,
150
+ );
151
+ }
152
+
153
+ export async function clientCredentials(opts: {
154
+ facts: OAuth2ProviderFacts;
155
+ clientId: string;
156
+ clientSecret: string;
157
+ scopes: readonly string[];
158
+ fetchImpl?: typeof fetch;
159
+ }): Promise<TokenSet> {
160
+ return tokenRequest(
161
+ opts.facts,
162
+ opts.clientId,
163
+ opts.clientSecret,
164
+ {
165
+ grant_type: "client_credentials",
166
+ ...(opts.scopes.length > 0 ? { scope: opts.scopes.join(opts.facts.scopeSeparator ?? " ") } : {}),
167
+ },
168
+ opts.fetchImpl ?? fetch,
169
+ );
170
+ }
package/src/config.ts ADDED
@@ -0,0 +1,128 @@
1
+ // Instance configuration — env-driven, defaults tuned for `tesser dev` (embedded PGlite,
2
+ // auto master key). Production fails fast on missing or unsafe required env.
3
+
4
+ import { mkdirSync, readFileSync, writeFileSync, existsSync, rmSync } from "node:fs";
5
+ import { join } from "node:path";
6
+ import { randomBytes } from "node:crypto";
7
+
8
+ export interface ServerConfig {
9
+ port: number;
10
+ /** Public base URL for webhooks/connect links (defaults to http://localhost:<port>). */
11
+ baseUrl: string;
12
+ databaseUrl: string | undefined;
13
+ dataDir: string;
14
+ masterKey: Buffer;
15
+ /** Which duties this process performs. */
16
+ roles: { api: boolean; worker: boolean; scheduler: boolean; reconciler: boolean };
17
+ workerTags: string[] | undefined;
18
+ pollIntervalMs: number;
19
+ env: "production" | "development";
20
+ }
21
+
22
+ export function loadConfig(env: NodeJS.ProcessEnv = process.env): ServerConfig {
23
+ const port = Number(env["PORT"] ?? env["TESSER_PORT"] ?? 8377);
24
+ const mode: ServerConfig["env"] = env["NODE_ENV"] === "production" ? "production" : "development";
25
+ const production = mode === "production";
26
+ const dataDir = env["TESSER_DATA_DIR"] ?? join(process.cwd(), ".tesser");
27
+ const databaseUrl = env["DATABASE_URL"] ?? env["TESSER_DATABASE_URL"];
28
+ const baseUrl = env["TESSER_BASE_URL"] ?? (production ? undefined : `http://localhost:${port}`);
29
+ const rolesEnv = env["TESSER_ROLES"]; // e.g. "api,worker" — dev default: everything
30
+
31
+ if (production) {
32
+ const missing = [
33
+ ...(databaseUrl ? [] : ["DATABASE_URL"]),
34
+ ...(baseUrl ? [] : ["TESSER_BASE_URL"]),
35
+ ...(env["TESSER_MASTER_KEY"] ? [] : ["TESSER_MASTER_KEY"]),
36
+ ...(rolesEnv ? [] : ["TESSER_ROLES"]),
37
+ ];
38
+ if (missing.length > 0) {
39
+ throw new Error(`production configuration missing required env: ${missing.join(", ")}`);
40
+ }
41
+ }
42
+
43
+ const normalizedBaseUrl = validateBaseUrl(baseUrl!, { production, allowLocalhost: env["TESSER_ALLOW_LOCALHOST_BASE_URL"] === "true" });
44
+ ensureWritableDataDir(dataDir);
45
+
46
+ let masterKey: Buffer;
47
+ const fromEnv = env["TESSER_MASTER_KEY"];
48
+ if (fromEnv) {
49
+ masterKey = Buffer.from(fromEnv, "base64");
50
+ if (masterKey.length !== 32) {
51
+ throw new Error("TESSER_MASTER_KEY must be 32 bytes, base64-encoded");
52
+ }
53
+ } else {
54
+ // Dev convenience: generate once into the data dir, loudly. Production should set
55
+ // TESSER_MASTER_KEY explicitly (custody/rotation: docs/SCOPES.md §4).
56
+ const keyPath = join(dataDir, "master.key");
57
+ if (existsSync(keyPath)) {
58
+ masterKey = Buffer.from(readFileSync(keyPath, "utf8").trim(), "base64");
59
+ } else {
60
+ masterKey = randomBytes(32);
61
+ writeFileSync(keyPath, masterKey.toString("base64") + "\n", { mode: 0o600 });
62
+ console.error(
63
+ `[tesser] generated a development master key at ${keyPath} — set TESSER_MASTER_KEY in production`,
64
+ );
65
+ }
66
+ }
67
+
68
+ const roleSet = parseRoles(rolesEnv ?? "api,worker,scheduler,reconciler");
69
+
70
+ return {
71
+ port,
72
+ baseUrl: normalizedBaseUrl,
73
+ databaseUrl,
74
+ dataDir,
75
+ masterKey,
76
+ roles: {
77
+ api: roleSet.has("api"),
78
+ worker: roleSet.has("worker"),
79
+ scheduler: roleSet.has("scheduler"),
80
+ reconciler: roleSet.has("reconciler"),
81
+ },
82
+ workerTags: env["TESSER_WORKER_TAGS"]?.split(",").map((tag) => tag.trim()).filter(Boolean),
83
+ pollIntervalMs: Number(env["TESSER_QUEUE_POLL_MS"] ?? 250),
84
+ env: mode,
85
+ };
86
+ }
87
+
88
+ function validateBaseUrl(input: string, opts: { production: boolean; allowLocalhost: boolean }): string {
89
+ let parsed: URL;
90
+ try {
91
+ parsed = new URL(input);
92
+ } catch {
93
+ throw new Error("TESSER_BASE_URL must be an absolute URL");
94
+ }
95
+ if (parsed.pathname !== "/" || parsed.search || parsed.hash) {
96
+ throw new Error("TESSER_BASE_URL must be an origin only, for example https://tesser.example.com");
97
+ }
98
+ if (opts.production && !opts.allowLocalhost && isLocalhost(parsed.hostname)) {
99
+ throw new Error("TESSER_BASE_URL must not be localhost in production; set a public origin");
100
+ }
101
+ return parsed.origin;
102
+ }
103
+
104
+ function isLocalhost(hostname: string): boolean {
105
+ const host = hostname.toLowerCase();
106
+ return host === "localhost" || host === "127.0.0.1" || host === "::1" || host === "[::1]";
107
+ }
108
+
109
+ function parseRoles(input: string): Set<string> {
110
+ const allowed = new Set(["api", "worker", "scheduler", "reconciler"]);
111
+ const roles = input.split(",").map((role) => role.trim()).filter(Boolean);
112
+ const unknown = roles.filter((role) => !allowed.has(role));
113
+ if (unknown.length > 0) throw new Error(`TESSER_ROLES includes unknown role(s): ${unknown.join(", ")}`);
114
+ if (roles.length === 0) throw new Error("TESSER_ROLES must include at least one role");
115
+ return new Set(roles);
116
+ }
117
+
118
+ function ensureWritableDataDir(dataDir: string): void {
119
+ try {
120
+ mkdirSync(dataDir, { recursive: true });
121
+ const probe = join(dataDir, `.tesser-write-probe-${randomBytes(8).toString("hex")}`);
122
+ writeFileSync(probe, "ok", { flag: "wx" });
123
+ rmSync(probe, { force: true });
124
+ } catch (err) {
125
+ const message = err instanceof Error ? err.message : String(err);
126
+ throw new Error(`TESSER_DATA_DIR must be writable for runtime data and artifacts: ${message}`);
127
+ }
128
+ }