@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.
- package/LICENSE +661 -0
- package/README.md +18 -0
- package/bin/tesser-server.mjs +2 -0
- package/dist/main.js +6296 -0
- package/dist/main.js.map +7 -0
- package/package.json +42 -0
- package/src/broker/broker.ts +332 -0
- package/src/broker/connect.ts +224 -0
- package/src/broker/connections.ts +278 -0
- package/src/broker/crypto.ts +39 -0
- package/src/broker/masking.ts +32 -0
- package/src/broker/oauth.ts +170 -0
- package/src/config.ts +128 -0
- package/src/db/db.ts +114 -0
- package/src/db/migrate.ts +35 -0
- package/src/db/migrations.ts +302 -0
- package/src/engine/executor.ts +536 -0
- package/src/engine/runs.ts +83 -0
- package/src/engine/signals.ts +18 -0
- package/src/engine/types.ts +53 -0
- package/src/events/fanout.ts +73 -0
- package/src/gitsync/build.ts +102 -0
- package/src/gitsync/deploy-keys.ts +59 -0
- package/src/gitsync/reconciler.ts +429 -0
- package/src/http/api.ts +425 -0
- package/src/http/app.ts +33 -0
- package/src/http/connect-view.ts +290 -0
- package/src/http/connect.ts +351 -0
- package/src/http/ingress.ts +204 -0
- package/src/http/status.ts +171 -0
- package/src/http/tokens.ts +46 -0
- package/src/index.ts +20 -0
- package/src/main.ts +26 -0
- package/src/queue/queue.ts +133 -0
- package/src/queue/worker.ts +85 -0
- package/src/registry/loader.ts +41 -0
- package/src/scheduler/cron.ts +115 -0
- package/src/scheduler/reaper.ts +105 -0
- package/src/server.ts +162 -0
- package/src/triggers/ingress.ts +154 -0
- package/src/triggers/poll.ts +167 -0
- package/src/triggers/registrar.ts +274 -0
- package/src/triggers/shared.ts +188 -0
package/package.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@devosurf/tesser-server",
|
|
3
|
+
"version": "0.1.0-alpha.0",
|
|
4
|
+
"description": "Tesser runtime, scheduler, credential broker, git-sync and control-plane.",
|
|
5
|
+
"license": "AGPL-3.0-only",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": {
|
|
8
|
+
"tesser-server": "./bin/tesser-server.mjs"
|
|
9
|
+
},
|
|
10
|
+
"exports": {
|
|
11
|
+
".": {
|
|
12
|
+
"types": "./src/index.ts",
|
|
13
|
+
"default": "./dist/main.js"
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
"dependencies": {
|
|
17
|
+
"@electric-sql/pglite": "^0.5.2",
|
|
18
|
+
"@hono/node-server": "^2.0.4",
|
|
19
|
+
"croner": "^10.0.1",
|
|
20
|
+
"esbuild": "^0.28.1",
|
|
21
|
+
"hono": "^4.12.25",
|
|
22
|
+
"pg": "^8.21.0",
|
|
23
|
+
"@devosurf/tesser-brand": "0.1.0-alpha.0",
|
|
24
|
+
"@devosurf/tesser-sdk": "0.1.0-alpha.0",
|
|
25
|
+
"@devosurf/tesser-testing": "^0.1.0-alpha.0"
|
|
26
|
+
},
|
|
27
|
+
"devDependencies": {
|
|
28
|
+
"@types/pg": "^8.20.0"
|
|
29
|
+
},
|
|
30
|
+
"publishConfig": {
|
|
31
|
+
"access": "public"
|
|
32
|
+
},
|
|
33
|
+
"main": "./dist/main.js",
|
|
34
|
+
"types": "./src/index.ts",
|
|
35
|
+
"files": [
|
|
36
|
+
"bin",
|
|
37
|
+
"dist",
|
|
38
|
+
"src",
|
|
39
|
+
"README.md",
|
|
40
|
+
"LICENSE"
|
|
41
|
+
]
|
|
42
|
+
}
|
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
// The Credential broker (ADR-0004/0005): the ONLY place credential values are decrypted.
|
|
2
|
+
// Automations hold handles; the broker injects values at call time and registers every
|
|
3
|
+
// decrypted value with the log masker. Refresh is single-flight per connection.
|
|
4
|
+
|
|
5
|
+
import type { OAuth2ProviderFacts } from "@devosurf/tesser-sdk/connector";
|
|
6
|
+
import type { Db } from "../db/db.js";
|
|
7
|
+
import { decrypt, encrypt, generateDataKey, unwrapDataKey, wrapDataKey } from "./crypto.js";
|
|
8
|
+
import { Masker } from "./masking.js";
|
|
9
|
+
import { refreshToken } from "./oauth.js";
|
|
10
|
+
|
|
11
|
+
export interface ConnectionRow {
|
|
12
|
+
id: string;
|
|
13
|
+
workspace_id: string;
|
|
14
|
+
connector_id: string;
|
|
15
|
+
provider: string | null;
|
|
16
|
+
auth_mode: string;
|
|
17
|
+
scope: string;
|
|
18
|
+
end_user_id: string | null;
|
|
19
|
+
label: string | null;
|
|
20
|
+
credential_cipher: string | null;
|
|
21
|
+
credential_meta: { expiresAt?: number; scope?: string; tokenType?: string };
|
|
22
|
+
status: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface CredentialBundle {
|
|
26
|
+
fields: Record<string, string>;
|
|
27
|
+
meta: ConnectionRow["credential_meta"];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export class Broker {
|
|
31
|
+
private dataKeys = new Map<string, Buffer>();
|
|
32
|
+
private refreshing = new Map<string, Promise<boolean>>();
|
|
33
|
+
|
|
34
|
+
constructor(
|
|
35
|
+
private readonly db: Db,
|
|
36
|
+
private readonly masterKey: Buffer,
|
|
37
|
+
readonly masker: Masker,
|
|
38
|
+
private readonly fetchImpl: typeof fetch = fetch,
|
|
39
|
+
) {}
|
|
40
|
+
|
|
41
|
+
// ---- workspaces & data keys ----
|
|
42
|
+
|
|
43
|
+
async ensureDefaultWorkspace(): Promise<string> {
|
|
44
|
+
const existing = await this.db.query<{ id: string }>(
|
|
45
|
+
`SELECT id FROM workspaces ORDER BY created_at LIMIT 1`,
|
|
46
|
+
);
|
|
47
|
+
if (existing.rows[0]) return existing.rows[0].id;
|
|
48
|
+
const wrapped = wrapDataKey(this.masterKey, generateDataKey());
|
|
49
|
+
const created = await this.db.query<{ id: string }>(
|
|
50
|
+
`INSERT INTO workspaces (name, data_key_cipher) VALUES ('default', $1) RETURNING id`,
|
|
51
|
+
[wrapped],
|
|
52
|
+
);
|
|
53
|
+
return created.rows[0]!.id;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
private async dataKey(workspaceId: string): Promise<Buffer> {
|
|
57
|
+
const cached = this.dataKeys.get(workspaceId);
|
|
58
|
+
if (cached) return cached;
|
|
59
|
+
const { rows } = await this.db.query<{ data_key_cipher: string }>(
|
|
60
|
+
`SELECT data_key_cipher FROM workspaces WHERE id = $1`,
|
|
61
|
+
[workspaceId],
|
|
62
|
+
);
|
|
63
|
+
if (!rows[0]) throw new Error(`workspace ${workspaceId} not found`);
|
|
64
|
+
const key = unwrapDataKey(this.masterKey, rows[0].data_key_cipher);
|
|
65
|
+
this.dataKeys.set(workspaceId, key);
|
|
66
|
+
return key;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ---- secrets (raw values, ADR-0010) ----
|
|
70
|
+
|
|
71
|
+
async setSecret(workspaceId: string, name: string, value: string): Promise<void> {
|
|
72
|
+
const cipher = encrypt(await this.dataKey(workspaceId), value);
|
|
73
|
+
await this.db.query(
|
|
74
|
+
`INSERT INTO secrets (workspace_id, name, value_cipher) VALUES ($1,$2,$3)
|
|
75
|
+
ON CONFLICT (workspace_id, name) DO UPDATE SET value_cipher = EXCLUDED.value_cipher, updated_at = now()`,
|
|
76
|
+
[workspaceId, name, cipher],
|
|
77
|
+
);
|
|
78
|
+
this.masker.add(value, `secret.${name}`);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async getSecretValue(workspaceId: string, name: string): Promise<string | null> {
|
|
82
|
+
const { rows } = await this.db.query<{ value_cipher: string }>(
|
|
83
|
+
`SELECT value_cipher FROM secrets WHERE workspace_id=$1 AND name=$2`,
|
|
84
|
+
[workspaceId, name],
|
|
85
|
+
);
|
|
86
|
+
if (!rows[0]) return null;
|
|
87
|
+
const value = decrypt(await this.dataKey(workspaceId), rows[0].value_cipher).toString("utf8");
|
|
88
|
+
this.masker.add(value, `secret.${name}`);
|
|
89
|
+
return value;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async listSecretNames(workspaceId: string): Promise<Array<{ name: string; updatedAt: string }>> {
|
|
93
|
+
const { rows } = await this.db.query<{ name: string; updated_at: string }>(
|
|
94
|
+
`SELECT name, updated_at FROM secrets WHERE workspace_id=$1 ORDER BY name`,
|
|
95
|
+
[workspaceId],
|
|
96
|
+
);
|
|
97
|
+
return rows.map((r) => ({ name: r.name, updatedAt: String(r.updated_at) }));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async deleteSecret(workspaceId: string, name: string): Promise<boolean> {
|
|
101
|
+
const { rowCount } = await this.db.query(`DELETE FROM secrets WHERE workspace_id=$1 AND name=$2`, [
|
|
102
|
+
workspaceId,
|
|
103
|
+
name,
|
|
104
|
+
]);
|
|
105
|
+
return rowCount > 0;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ---- connections ----
|
|
109
|
+
|
|
110
|
+
async createConnection(opts: {
|
|
111
|
+
workspaceId: string;
|
|
112
|
+
connectorId: string;
|
|
113
|
+
provider?: string | undefined;
|
|
114
|
+
authMode?: string;
|
|
115
|
+
scope?: "workspace" | "per_user";
|
|
116
|
+
endUserId?: string;
|
|
117
|
+
label?: string;
|
|
118
|
+
}): Promise<string> {
|
|
119
|
+
const { rows } = await this.db.query<{ id: string }>(
|
|
120
|
+
`INSERT INTO connections (workspace_id, connector_id, provider, auth_mode, scope, end_user_id, label, status)
|
|
121
|
+
VALUES ($1,$2,$3,$4,$5,$6,$7,'pending') RETURNING id`,
|
|
122
|
+
[
|
|
123
|
+
opts.workspaceId,
|
|
124
|
+
opts.connectorId,
|
|
125
|
+
opts.provider ?? null,
|
|
126
|
+
opts.authMode ?? "default",
|
|
127
|
+
opts.scope ?? "workspace",
|
|
128
|
+
opts.endUserId ?? null,
|
|
129
|
+
opts.label ?? null,
|
|
130
|
+
],
|
|
131
|
+
);
|
|
132
|
+
return rows[0]!.id;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async setConnectionCredential(
|
|
136
|
+
connectionId: string,
|
|
137
|
+
fields: Record<string, string>,
|
|
138
|
+
meta: ConnectionRow["credential_meta"] = {},
|
|
139
|
+
): Promise<void> {
|
|
140
|
+
const { rows } = await this.db.query<{ workspace_id: string; connector_id: string }>(
|
|
141
|
+
`SELECT workspace_id, connector_id FROM connections WHERE id=$1`,
|
|
142
|
+
[connectionId],
|
|
143
|
+
);
|
|
144
|
+
if (!rows[0]) throw new Error(`connection ${connectionId} not found`);
|
|
145
|
+
const cipher = encrypt(await this.dataKey(rows[0].workspace_id), JSON.stringify(fields));
|
|
146
|
+
await this.db.query(
|
|
147
|
+
`UPDATE connections SET credential_cipher=$2, credential_meta=$3::jsonb, status='ready', error=NULL, updated_at=now()
|
|
148
|
+
WHERE id=$1`,
|
|
149
|
+
[connectionId, cipher, JSON.stringify(meta)],
|
|
150
|
+
);
|
|
151
|
+
this.masker.addFields(fields, `connection.${rows[0].connector_id}`);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async getConnection(connectionId: string): Promise<ConnectionRow | null> {
|
|
155
|
+
const { rows } = await this.db.query<ConnectionRow>(`SELECT * FROM connections WHERE id=$1`, [connectionId]);
|
|
156
|
+
return rows[0] ?? null;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async getCredential(connectionId: string): Promise<CredentialBundle> {
|
|
160
|
+
const row = await this.getConnection(connectionId);
|
|
161
|
+
if (!row || row.status !== "ready" || !row.credential_cipher) {
|
|
162
|
+
throw new Error(`connection ${connectionId} is not ready`);
|
|
163
|
+
}
|
|
164
|
+
const fields = JSON.parse(
|
|
165
|
+
decrypt(await this.dataKey(row.workspace_id), row.credential_cipher).toString("utf8"),
|
|
166
|
+
) as Record<string, string>;
|
|
167
|
+
this.masker.addFields(fields, `connection.${row.connector_id}`);
|
|
168
|
+
return { fields, meta: row.credential_meta ?? {} };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async listConnections(workspaceId: string): Promise<
|
|
172
|
+
Array<{ id: string; connectorId: string; authMode: string; scope: string; label: string | null; status: string; endUserId: string | null }>
|
|
173
|
+
> {
|
|
174
|
+
const { rows } = await this.db.query<ConnectionRow>(
|
|
175
|
+
`SELECT * FROM connections WHERE workspace_id=$1 ORDER BY created_at`,
|
|
176
|
+
[workspaceId],
|
|
177
|
+
);
|
|
178
|
+
return rows.map((r) => ({
|
|
179
|
+
id: r.id,
|
|
180
|
+
connectorId: r.connector_id,
|
|
181
|
+
authMode: r.auth_mode,
|
|
182
|
+
scope: r.scope,
|
|
183
|
+
label: r.label,
|
|
184
|
+
status: r.status,
|
|
185
|
+
endUserId: r.end_user_id,
|
|
186
|
+
}));
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Resolve an automation's `connections.<reqKey>` to a concrete ready Connection —
|
|
190
|
+
* the persisted binding wins; otherwise auto-bind when exactly one candidate exists. */
|
|
191
|
+
async resolveBinding(opts: {
|
|
192
|
+
workspaceId: string;
|
|
193
|
+
projectId: string;
|
|
194
|
+
automationId: string;
|
|
195
|
+
env: string;
|
|
196
|
+
reqKey: string;
|
|
197
|
+
connectorId: string;
|
|
198
|
+
scope: "workspace" | "per_user";
|
|
199
|
+
endUserId?: string | undefined;
|
|
200
|
+
}): Promise<ConnectionRow | null> {
|
|
201
|
+
if (opts.scope === "workspace") {
|
|
202
|
+
const bound = await this.db.query<{ connection_id: string }>(
|
|
203
|
+
`SELECT connection_id FROM requirement_bindings
|
|
204
|
+
WHERE project_id=$1 AND automation_id=$2 AND env=$3 AND req_key=$4`,
|
|
205
|
+
[opts.projectId, opts.automationId, opts.env, opts.reqKey],
|
|
206
|
+
);
|
|
207
|
+
if (bound.rows[0]) {
|
|
208
|
+
const conn = await this.getConnection(bound.rows[0].connection_id);
|
|
209
|
+
if (conn?.status === "ready") return conn;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
const endUserFilter = opts.scope === "per_user" ? `AND end_user_id = $4` : `AND scope = 'workspace'`;
|
|
213
|
+
const params: unknown[] =
|
|
214
|
+
opts.scope === "per_user"
|
|
215
|
+
? [opts.workspaceId, opts.connectorId, "ready", opts.endUserId ?? null]
|
|
216
|
+
: [opts.workspaceId, opts.connectorId, "ready"];
|
|
217
|
+
const { rows } = await this.db.query<ConnectionRow>(
|
|
218
|
+
`SELECT * FROM connections WHERE workspace_id=$1 AND connector_id=$2 AND status=$3 ${endUserFilter}
|
|
219
|
+
ORDER BY created_at`,
|
|
220
|
+
params,
|
|
221
|
+
);
|
|
222
|
+
if (rows.length !== 1) return null; // 0 = missing; >1 = ambiguous → must bind explicitly
|
|
223
|
+
const conn = rows[0]!;
|
|
224
|
+
if (opts.scope === "workspace") {
|
|
225
|
+
await this.db.query(
|
|
226
|
+
`INSERT INTO requirement_bindings (project_id, automation_id, env, req_key, connection_id)
|
|
227
|
+
VALUES ($1,$2,$3,$4,$5)
|
|
228
|
+
ON CONFLICT (project_id, automation_id, env, req_key) DO UPDATE SET connection_id = EXCLUDED.connection_id`,
|
|
229
|
+
[opts.projectId, opts.automationId, opts.env, opts.reqKey, conn.id],
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
return conn;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// ---- OAuth apps (BYO per workspace, ADR-0005; env fallback for dev) ----
|
|
236
|
+
|
|
237
|
+
async setOAuthApp(workspaceId: string, provider: string, clientId: string, clientSecret: string): Promise<void> {
|
|
238
|
+
const cipher = encrypt(await this.dataKey(workspaceId), clientSecret);
|
|
239
|
+
await this.db.query(
|
|
240
|
+
`INSERT INTO oauth_apps (workspace_id, provider, client_id, client_secret_cipher)
|
|
241
|
+
VALUES ($1,$2,$3,$4)
|
|
242
|
+
ON CONFLICT (workspace_id, provider) DO UPDATE SET client_id=EXCLUDED.client_id, client_secret_cipher=EXCLUDED.client_secret_cipher`,
|
|
243
|
+
[workspaceId, provider, clientId, cipher],
|
|
244
|
+
);
|
|
245
|
+
this.masker.add(clientSecret, `oauthapp.${provider}`);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
async getOAuthApp(
|
|
249
|
+
workspaceId: string,
|
|
250
|
+
provider: string,
|
|
251
|
+
): Promise<{ clientId: string; clientSecret: string } | null> {
|
|
252
|
+
const { rows } = await this.db.query<{ client_id: string; client_secret_cipher: string }>(
|
|
253
|
+
`SELECT client_id, client_secret_cipher FROM oauth_apps WHERE workspace_id=$1 AND provider=$2`,
|
|
254
|
+
[workspaceId, provider],
|
|
255
|
+
);
|
|
256
|
+
if (rows[0]) {
|
|
257
|
+
const clientSecret = decrypt(await this.dataKey(workspaceId), rows[0].client_secret_cipher).toString("utf8");
|
|
258
|
+
this.masker.add(clientSecret, `oauthapp.${provider}`);
|
|
259
|
+
return { clientId: rows[0].client_id, clientSecret };
|
|
260
|
+
}
|
|
261
|
+
const envKey = provider.toUpperCase().replace(/[^A-Z0-9]/g, "_");
|
|
262
|
+
const clientId = process.env[`TESSER_OAUTH_${envKey}_CLIENT_ID`];
|
|
263
|
+
const clientSecret = process.env[`TESSER_OAUTH_${envKey}_CLIENT_SECRET`];
|
|
264
|
+
if (clientId && clientSecret) return { clientId, clientSecret };
|
|
265
|
+
return null;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// ---- token freshness (refresh-once, single-flight per connection) ----
|
|
269
|
+
|
|
270
|
+
/** Returns true when a refresh happened (caller should re-read the credential). */
|
|
271
|
+
async refreshConnection(connectionId: string, facts: OAuth2ProviderFacts): Promise<boolean> {
|
|
272
|
+
const inFlight = this.refreshing.get(connectionId);
|
|
273
|
+
if (inFlight) return inFlight;
|
|
274
|
+
const p = (async (): Promise<boolean> => {
|
|
275
|
+
const row = await this.getConnection(connectionId);
|
|
276
|
+
if (!row) return false;
|
|
277
|
+
const { fields, meta } = await this.getCredential(connectionId);
|
|
278
|
+
const rt = fields["refresh_token"];
|
|
279
|
+
if (rt === undefined) return false;
|
|
280
|
+
const app = await this.getOAuthApp(row.workspace_id, row.provider ?? row.connector_id);
|
|
281
|
+
if (!app) return false;
|
|
282
|
+
const tokens = await refreshToken({
|
|
283
|
+
facts,
|
|
284
|
+
clientId: app.clientId,
|
|
285
|
+
clientSecret: app.clientSecret,
|
|
286
|
+
refreshToken: rt,
|
|
287
|
+
fetchImpl: this.fetchImpl,
|
|
288
|
+
});
|
|
289
|
+
const nextFields: Record<string, string> = {
|
|
290
|
+
...fields,
|
|
291
|
+
access_token: tokens.accessToken,
|
|
292
|
+
...(tokens.refreshToken !== undefined ? { refresh_token: tokens.refreshToken } : {}),
|
|
293
|
+
};
|
|
294
|
+
await this.setConnectionCredential(connectionId, nextFields, {
|
|
295
|
+
...meta,
|
|
296
|
+
...(tokens.expiresAt !== undefined ? { expiresAt: tokens.expiresAt } : {}),
|
|
297
|
+
});
|
|
298
|
+
return true;
|
|
299
|
+
})().finally(() => this.refreshing.delete(connectionId));
|
|
300
|
+
this.refreshing.set(connectionId, p);
|
|
301
|
+
return p;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** Workspace-keyed encryption for values that live outside the secrets table
|
|
305
|
+
* (deploy keys, webhook signing secrets). */
|
|
306
|
+
async encryptValue(workspaceId: string, value: string, maskLabel?: string): Promise<string> {
|
|
307
|
+
if (maskLabel !== undefined) this.masker.add(value, maskLabel);
|
|
308
|
+
return encrypt(await this.dataKey(workspaceId), value);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
async decryptValue(workspaceId: string, cipher: string, maskLabel?: string): Promise<string> {
|
|
312
|
+
const value = decrypt(await this.dataKey(workspaceId), cipher).toString("utf8");
|
|
313
|
+
if (maskLabel !== undefined) this.masker.add(value, maskLabel);
|
|
314
|
+
return value;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** Ensure a fresh access token (60s slack) before injecting. */
|
|
318
|
+
async freshCredential(connectionId: string, facts: OAuth2ProviderFacts | undefined): Promise<CredentialBundle> {
|
|
319
|
+
let bundle = await this.getCredential(connectionId);
|
|
320
|
+
const expiresAt = bundle.meta.expiresAt;
|
|
321
|
+
if (
|
|
322
|
+
facts !== undefined &&
|
|
323
|
+
typeof expiresAt === "number" &&
|
|
324
|
+
expiresAt < Date.now() + 60_000 &&
|
|
325
|
+
bundle.fields["refresh_token"] !== undefined
|
|
326
|
+
) {
|
|
327
|
+
const refreshed = await this.refreshConnection(connectionId, facts);
|
|
328
|
+
if (refreshed) bundle = await this.getCredential(connectionId);
|
|
329
|
+
}
|
|
330
|
+
return bundle;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
// The credential handoff (ADR-0005): deploy halts on missing requirements; the server
|
|
2
|
+
// mints a short-lived single-use connect link; a human completes OAuth / pastes values
|
|
3
|
+
// in their browser; the agent only ever polls a status.
|
|
4
|
+
|
|
5
|
+
import { randomBytes } from "node:crypto";
|
|
6
|
+
import type { ConnectorManifest } from "@devosurf/tesser-sdk/internal";
|
|
7
|
+
import type { ProviderFacts } from "@devosurf/tesser-sdk/connector";
|
|
8
|
+
import type { Db } from "../db/db.js";
|
|
9
|
+
import type { Broker } from "./broker.js";
|
|
10
|
+
|
|
11
|
+
export interface ConnectionRequirement {
|
|
12
|
+
type: "connection";
|
|
13
|
+
key: string;
|
|
14
|
+
connector: string;
|
|
15
|
+
scope: "workspace" | "per_user";
|
|
16
|
+
/** Auth modes the human can choose between (from the connector manifest). */
|
|
17
|
+
modes: Array<{
|
|
18
|
+
name: string;
|
|
19
|
+
kind: "oauth2" | "apiKey" | "basic" | "custom";
|
|
20
|
+
describe?: string;
|
|
21
|
+
/** Paste-field names for non-OAuth modes. */
|
|
22
|
+
fields: string[];
|
|
23
|
+
scopes?: string[];
|
|
24
|
+
}>;
|
|
25
|
+
provider?: string;
|
|
26
|
+
providerFacts?: ProviderFacts;
|
|
27
|
+
automations: string[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface SecretRequirement {
|
|
31
|
+
type: "secret";
|
|
32
|
+
name: string;
|
|
33
|
+
describe?: string;
|
|
34
|
+
automations: string[];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface ManualWebhookRequirement {
|
|
38
|
+
type: "webhook-manual";
|
|
39
|
+
registrationId: string;
|
|
40
|
+
connector: string;
|
|
41
|
+
trigger: string;
|
|
42
|
+
automation: string;
|
|
43
|
+
instructions: string;
|
|
44
|
+
url: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export type Requirement = ConnectionRequirement | SecretRequirement | ManualWebhookRequirement;
|
|
48
|
+
|
|
49
|
+
export interface AutomationRequirementSource {
|
|
50
|
+
automationId: string;
|
|
51
|
+
connections: Record<string, { connector: string; scope: "workspace" | "per_user" }>;
|
|
52
|
+
secrets: Record<string, { describe?: string }>;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function fieldsFor(kind: string, declared?: string[]): string[] {
|
|
56
|
+
switch (kind) {
|
|
57
|
+
case "apiKey":
|
|
58
|
+
return ["api_key"];
|
|
59
|
+
case "basic":
|
|
60
|
+
return ["username", "password"];
|
|
61
|
+
case "custom":
|
|
62
|
+
return declared ?? [];
|
|
63
|
+
default:
|
|
64
|
+
return [];
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Compute what is still missing for a set of automations (deploy-halt gate). */
|
|
69
|
+
export async function computeMissingRequirements(opts: {
|
|
70
|
+
db: Db;
|
|
71
|
+
broker: Broker;
|
|
72
|
+
workspaceId: string;
|
|
73
|
+
projectId: string;
|
|
74
|
+
env: string;
|
|
75
|
+
automations: AutomationRequirementSource[];
|
|
76
|
+
connectorManifests: Record<string, ConnectorManifest & { providerFacts?: ProviderFacts }>;
|
|
77
|
+
}): Promise<Requirement[]> {
|
|
78
|
+
const connReqs = new Map<string, ConnectionRequirement>();
|
|
79
|
+
const secretReqs = new Map<string, SecretRequirement>();
|
|
80
|
+
|
|
81
|
+
for (const auto of opts.automations) {
|
|
82
|
+
for (const [key, decl] of Object.entries(auto.connections)) {
|
|
83
|
+
let satisfied = false;
|
|
84
|
+
if (decl.scope === "per_user") {
|
|
85
|
+
// Deploy-time validation cannot know every future end user. For per-user
|
|
86
|
+
// requirements, the production gate only proves that this Instance has at
|
|
87
|
+
// least one non-null end-user Connection for the Connector; runtime still
|
|
88
|
+
// resolves the exact endUserId and fails closed for unknown users.
|
|
89
|
+
const { rows } = await opts.db.query(
|
|
90
|
+
`SELECT 1 FROM connections
|
|
91
|
+
WHERE workspace_id=$1 AND connector_id=$2 AND status='ready' AND scope='per_user' AND end_user_id IS NOT NULL
|
|
92
|
+
LIMIT 1`,
|
|
93
|
+
[opts.workspaceId, decl.connector],
|
|
94
|
+
);
|
|
95
|
+
satisfied = rows.length > 0;
|
|
96
|
+
} else {
|
|
97
|
+
const resolved = await opts.broker.resolveBinding({
|
|
98
|
+
workspaceId: opts.workspaceId,
|
|
99
|
+
projectId: opts.projectId,
|
|
100
|
+
automationId: auto.automationId,
|
|
101
|
+
env: opts.env,
|
|
102
|
+
reqKey: key,
|
|
103
|
+
connectorId: decl.connector,
|
|
104
|
+
scope: decl.scope,
|
|
105
|
+
});
|
|
106
|
+
satisfied = resolved !== null;
|
|
107
|
+
}
|
|
108
|
+
if (satisfied) continue;
|
|
109
|
+
const manifest = opts.connectorManifests[decl.connector];
|
|
110
|
+
const id = `${decl.connector}:${decl.scope}`;
|
|
111
|
+
const existing = connReqs.get(id);
|
|
112
|
+
if (existing) {
|
|
113
|
+
if (!existing.automations.includes(auto.automationId)) existing.automations.push(auto.automationId);
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
const modes = Object.entries(manifest?.auth ?? { default: { kind: "apiKey" as const, in: "header", name: "Authorization" } }).map(
|
|
117
|
+
([name, decl2]) => ({
|
|
118
|
+
name,
|
|
119
|
+
kind: decl2.kind as ConnectionRequirement["modes"][number]["kind"],
|
|
120
|
+
fields: fieldsFor(decl2.kind, (decl2 as { fields?: string[] }).fields),
|
|
121
|
+
...("describe" in decl2 && decl2.describe !== undefined ? { describe: decl2.describe } : {}),
|
|
122
|
+
...(decl2.kind === "oauth2" && "scopes" in decl2 ? { scopes: decl2.scopes } : {}),
|
|
123
|
+
}),
|
|
124
|
+
);
|
|
125
|
+
connReqs.set(id, {
|
|
126
|
+
type: "connection",
|
|
127
|
+
key,
|
|
128
|
+
connector: decl.connector,
|
|
129
|
+
scope: decl.scope,
|
|
130
|
+
modes,
|
|
131
|
+
...(manifest?.provider !== undefined ? { provider: manifest.provider } : {}),
|
|
132
|
+
...(manifest?.providerFacts !== undefined ? { providerFacts: manifest.providerFacts } : {}),
|
|
133
|
+
automations: [auto.automationId],
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
for (const [name, decl] of Object.entries(auto.secrets)) {
|
|
137
|
+
const value = await opts.broker.getSecretValue(opts.workspaceId, name);
|
|
138
|
+
if (value !== null) continue;
|
|
139
|
+
const existing = secretReqs.get(name);
|
|
140
|
+
if (existing) {
|
|
141
|
+
if (!existing.automations.includes(auto.automationId)) existing.automations.push(auto.automationId);
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
secretReqs.set(name, {
|
|
145
|
+
type: "secret",
|
|
146
|
+
name,
|
|
147
|
+
...(decl.describe !== undefined ? { describe: decl.describe } : {}),
|
|
148
|
+
automations: [auto.automationId],
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return [...connReqs.values(), ...secretReqs.values()];
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export async function mintConnectLink(opts: {
|
|
156
|
+
db: Db;
|
|
157
|
+
workspaceId: string;
|
|
158
|
+
projectId?: string | undefined;
|
|
159
|
+
requirements: Requirement[];
|
|
160
|
+
ttlMs?: number;
|
|
161
|
+
}): Promise<string> {
|
|
162
|
+
const token = `cl_${randomBytes(18).toString("hex")}`;
|
|
163
|
+
await opts.db.query(
|
|
164
|
+
`INSERT INTO connect_links (token, workspace_id, project_id, requirements, expires_at)
|
|
165
|
+
VALUES ($1,$2,$3,$4::jsonb, now() + ($5 || ' milliseconds')::interval)`,
|
|
166
|
+
[token, opts.workspaceId, opts.projectId ?? null, JSON.stringify(opts.requirements), String(opts.ttlMs ?? 24 * 3600 * 1000)],
|
|
167
|
+
);
|
|
168
|
+
return token;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export interface ConnectLinkRow {
|
|
172
|
+
token: string;
|
|
173
|
+
workspace_id: string;
|
|
174
|
+
project_id: string | null;
|
|
175
|
+
requirements: Requirement[];
|
|
176
|
+
status: string;
|
|
177
|
+
expires_at: string;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export async function getConnectLink(db: Db, token: string): Promise<ConnectLinkRow | null> {
|
|
181
|
+
const { rows } = await db.query<ConnectLinkRow>(`SELECT * FROM connect_links WHERE token=$1`, [token]);
|
|
182
|
+
const link = rows[0];
|
|
183
|
+
if (!link) return null;
|
|
184
|
+
if (link.status === "pending" && new Date(String(link.expires_at)).getTime() < Date.now()) {
|
|
185
|
+
await db.query(`UPDATE connect_links SET status='expired' WHERE token=$1`, [token]);
|
|
186
|
+
link.status = "expired";
|
|
187
|
+
}
|
|
188
|
+
return link;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Per-requirement satisfaction + link completion. */
|
|
192
|
+
export async function connectLinkStatus(
|
|
193
|
+
db: Db,
|
|
194
|
+
broker: Broker,
|
|
195
|
+
link: ConnectLinkRow,
|
|
196
|
+
): Promise<{ status: string; requirements: Array<{ requirement: Requirement; satisfied: boolean }> }> {
|
|
197
|
+
const detail: Array<{ requirement: Requirement; satisfied: boolean }> = [];
|
|
198
|
+
for (const req of link.requirements) {
|
|
199
|
+
let satisfied = false;
|
|
200
|
+
if (req.type === "connection") {
|
|
201
|
+
const scopeFilter = req.scope === "per_user" ? `AND scope='per_user' AND end_user_id IS NOT NULL` : `AND scope='workspace'`;
|
|
202
|
+
const { rows } = await db.query(
|
|
203
|
+
`SELECT 1 FROM connections WHERE workspace_id=$1 AND connector_id=$2 AND status='ready' ${scopeFilter} LIMIT 1`,
|
|
204
|
+
[link.workspace_id, req.connector],
|
|
205
|
+
);
|
|
206
|
+
satisfied = rows.length > 0;
|
|
207
|
+
} else if (req.type === "secret") {
|
|
208
|
+
satisfied = (await broker.getSecretValue(link.workspace_id, req.name)) !== null;
|
|
209
|
+
} else {
|
|
210
|
+
const { rows } = await db.query<{ status: string }>(
|
|
211
|
+
`SELECT status FROM webhook_registrations WHERE id=$1`,
|
|
212
|
+
[req.registrationId],
|
|
213
|
+
);
|
|
214
|
+
satisfied = rows[0]?.status === "registered";
|
|
215
|
+
}
|
|
216
|
+
detail.push({ requirement: req, satisfied });
|
|
217
|
+
}
|
|
218
|
+
const allSatisfied = detail.every((d) => d.satisfied);
|
|
219
|
+
if (allSatisfied && link.status === "pending") {
|
|
220
|
+
await db.query(`UPDATE connect_links SET status='completed', completed_at=now() WHERE token=$1`, [link.token]);
|
|
221
|
+
link.status = "completed";
|
|
222
|
+
}
|
|
223
|
+
return { status: link.status, requirements: detail };
|
|
224
|
+
}
|