@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
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
// Rendering for the connect pages — pure functions from view data to HTML.
|
|
2
|
+
// Styled by @devosurf/tesser-brand; no styling decisions live in this file beyond
|
|
3
|
+
// composing the t- primitives. The requirement cards are manifest-driven, so a
|
|
4
|
+
// custom connector renders exactly like a built-in one: a monogram tile, the
|
|
5
|
+
// modes its manifest declares, and nothing hardcoded per provider.
|
|
6
|
+
|
|
7
|
+
import { inlineCss, markSvg } from "@devosurf/tesser-brand";
|
|
8
|
+
import type { ConnectionRequirement, Requirement } from "../broker/connect.js";
|
|
9
|
+
|
|
10
|
+
export const esc = (s: unknown): string =>
|
|
11
|
+
String(s ?? "").replace(/[&<>"']/g, (ch) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[ch]!);
|
|
12
|
+
|
|
13
|
+
const CSS = inlineCss();
|
|
14
|
+
|
|
15
|
+
const ARROW = `<svg width="14" height="14" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><path d="M5 12h14M13 6l6 6-6 6" fill="none" stroke="currentColor" stroke-width="2.3" stroke-linecap="round" stroke-linejoin="round"/></svg>`;
|
|
16
|
+
const SHIELD = `<svg width="15" height="15" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><path d="M12 3l7 3v5c0 4.5-3 7.5-7 9-4-1.5-7-4.5-7-9V6l7-3Z" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/><path d="M9 12l2 2 4-4" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg>`;
|
|
17
|
+
|
|
18
|
+
export interface ShellOptions {
|
|
19
|
+
/** Browser tab title; the page headline carries the brand, the tab gets "· tesser". */
|
|
20
|
+
title: string;
|
|
21
|
+
/** Instance host shown in the top bar. */
|
|
22
|
+
host: string;
|
|
23
|
+
/** Link-state chip in the top bar. */
|
|
24
|
+
chip?: { dot: "positive" | "warning" | "danger"; label: string };
|
|
25
|
+
headline: string;
|
|
26
|
+
/** May contain markup; callers escape interpolations. */
|
|
27
|
+
lead?: string;
|
|
28
|
+
body?: string;
|
|
29
|
+
/** The encrypted-store footnote; on for pages that accept values. */
|
|
30
|
+
securityNote?: boolean;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function shell(o: ShellOptions): string {
|
|
34
|
+
const chip = o.chip
|
|
35
|
+
? `<span class="t-chip"><span class="t-dot t-dot--${o.chip.dot}"></span>${esc(o.chip.label)}</span>`
|
|
36
|
+
: "";
|
|
37
|
+
return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
38
|
+
<title>${esc(o.title)} · tesser</title>
|
|
39
|
+
<style>${CSS}</style></head><body class="t-page">
|
|
40
|
+
<header class="t-bar">
|
|
41
|
+
<span class="t-brand">${markSvg({ size: 22 })}<span class="t-wordmark">tesser</span></span>
|
|
42
|
+
<span class="t-bar-meta">${chip}<span class="t-host">${esc(o.host)}</span></span>
|
|
43
|
+
</header>
|
|
44
|
+
<main class="t-main"><div class="t-col">
|
|
45
|
+
<h1 class="t-page-title">${esc(o.headline)}</h1>
|
|
46
|
+
${o.lead ? `<p class="t-lead">${o.lead}</p>` : ""}
|
|
47
|
+
${o.body ?? ""}
|
|
48
|
+
${o.securityNote ? `<p class="t-footnote">${SHIELD}<span>Single use and expiring. Values go to the encrypted store. The agent holds a handle, never the value.</span></p>` : ""}
|
|
49
|
+
</div></main>
|
|
50
|
+
</body></html>`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/* requirement cards ----------------------------------------------------- */
|
|
54
|
+
|
|
55
|
+
export interface RequirementView {
|
|
56
|
+
requirement: Requirement;
|
|
57
|
+
satisfied: boolean;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Monogram tile glyph: the first character of the thing's own name, any connector. */
|
|
61
|
+
const glyph = (id: string): string => (id.trim().charAt(0) || "?").toUpperCase();
|
|
62
|
+
|
|
63
|
+
const status = (satisfied: boolean, pendingLabel: string, readyLabel: string): string =>
|
|
64
|
+
satisfied
|
|
65
|
+
? `<span class="t-status t-status--ready t-right"><span class="t-dot t-dot--positive"></span>${esc(readyLabel)}</span>`
|
|
66
|
+
: `<span class="t-status t-status--pending t-right"><span class="t-dot t-dot--warning"></span>${esc(pendingLabel)}</span>`;
|
|
67
|
+
|
|
68
|
+
const head = (o: { glyph: string; title: string; mono?: boolean; kind: string; meta: string; status: string }): string =>
|
|
69
|
+
`<div class="t-card-head">
|
|
70
|
+
<span class="t-tile" aria-hidden="true">${esc(o.glyph)}</span>
|
|
71
|
+
<span class="t-card-id">
|
|
72
|
+
<span class="t-card-title-row"><span class="t-card-title${o.mono ? " t-card-title--mono" : ""}">${esc(o.title)}</span><span class="t-kind">${esc(o.kind)}</span></span>
|
|
73
|
+
<span class="t-meta">${esc(o.meta)}</span>
|
|
74
|
+
</span>
|
|
75
|
+
${o.status}
|
|
76
|
+
</div>`;
|
|
77
|
+
|
|
78
|
+
const neededBy = (automations: string[]): string => `Needed by ${automations.join(", ")}`;
|
|
79
|
+
|
|
80
|
+
const fieldInput = (name: string, label: string): string => {
|
|
81
|
+
const type = /key|token|password|secret/i.test(label) ? "password" : "text";
|
|
82
|
+
return `<label class="t-field"><span class="t-field-label">${esc(label.replace(/_/g, " "))}</span><input class="t-input" name="${esc(name)}" type="${type}" required autocomplete="off"></label>`;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
function endUserInput(req: ConnectionRequirement): string {
|
|
86
|
+
return req.scope === "per_user" ? fieldInput("end_user_id", "end user id") : "";
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function connectionBands(token: string, req: ConnectionRequirement): string {
|
|
90
|
+
return req.modes
|
|
91
|
+
.map((mode) => {
|
|
92
|
+
if (mode.kind === "oauth2") {
|
|
93
|
+
const note = mode.scopes?.length ? `scopes · ${mode.scopes.join(" · ")}` : `mode · ${mode.name}`;
|
|
94
|
+
return `<form class="t-card-band${req.scope === "per_user" ? " t-card-band--stacked" : ""}" method="get" action="/connect/${esc(token)}/oauth/start">
|
|
95
|
+
<input type="hidden" name="connector" value="${esc(req.connector)}">
|
|
96
|
+
<input type="hidden" name="mode" value="${esc(mode.name)}">
|
|
97
|
+
<input type="hidden" name="scope" value="${esc(req.scope)}">
|
|
98
|
+
<span class="t-scopes">${esc(note)}${req.scope === "per_user" ? " · end user required" : ""}</span>
|
|
99
|
+
${endUserInput(req)}
|
|
100
|
+
<button class="t-btn t-btn--accent${req.scope === "per_user" ? "" : " t-right"}">Connect with OAuth ${ARROW}</button>
|
|
101
|
+
</form>`;
|
|
102
|
+
}
|
|
103
|
+
const hidden = `<input type="hidden" name="connector" value="${esc(req.connector)}">
|
|
104
|
+
<input type="hidden" name="mode" value="${esc(mode.name)}">
|
|
105
|
+
<input type="hidden" name="scope" value="${esc(req.scope)}">`;
|
|
106
|
+
if (mode.fields.length === 0) {
|
|
107
|
+
return `<form class="t-card-band${req.scope === "per_user" ? " t-card-band--stacked" : ""}" method="post" action="/connect/${esc(token)}/connection">
|
|
108
|
+
${hidden}
|
|
109
|
+
<span class="t-scopes">mode · ${esc(mode.name)}${req.scope === "per_user" ? " · end user required" : ""}</span>
|
|
110
|
+
${endUserInput(req)}
|
|
111
|
+
<button class="t-btn t-btn--primary${req.scope === "per_user" ? "" : " t-right"}">Create connection</button>
|
|
112
|
+
</form>`;
|
|
113
|
+
}
|
|
114
|
+
return `<div class="t-card-band t-card-band--stacked">
|
|
115
|
+
<span class="t-scopes">mode · ${esc(mode.name)}${mode.describe ? ` · ${esc(mode.describe)}` : ""}</span>
|
|
116
|
+
<form class="t-form-col" method="post" action="/connect/${esc(token)}/connection">
|
|
117
|
+
${hidden}
|
|
118
|
+
${endUserInput(req)}
|
|
119
|
+
${mode.fields.map((f) => fieldInput(`field_${f}`, f)).join("\n")}
|
|
120
|
+
<button class="t-btn t-btn--primary">Save credential</button>
|
|
121
|
+
</form>
|
|
122
|
+
</div>`;
|
|
123
|
+
})
|
|
124
|
+
.join("");
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function requirementCard(token: string, view: RequirementView): string {
|
|
128
|
+
const { requirement: req, satisfied } = view;
|
|
129
|
+
|
|
130
|
+
if (req.type === "connection") {
|
|
131
|
+
const scope = req.scope === "per_user" ? "per user" : req.scope;
|
|
132
|
+
const top = head({
|
|
133
|
+
glyph: glyph(req.connector),
|
|
134
|
+
title: req.connector,
|
|
135
|
+
kind: "connection",
|
|
136
|
+
meta: `${satisfied ? "Connected · " : ""}${neededBy(req.automations)} · ${scope}`,
|
|
137
|
+
status: status(satisfied, "pending", "ready"),
|
|
138
|
+
});
|
|
139
|
+
return `<div class="t-card">${top}${satisfied ? "" : connectionBands(token, req)}</div>`;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (req.type === "secret") {
|
|
143
|
+
const top = head({
|
|
144
|
+
glyph: glyph(req.name),
|
|
145
|
+
title: req.name,
|
|
146
|
+
mono: true,
|
|
147
|
+
kind: "secret",
|
|
148
|
+
meta: `${req.describe ? `${req.describe} · ` : ""}${neededBy(req.automations)}`,
|
|
149
|
+
status: status(satisfied, "pending", "set"),
|
|
150
|
+
});
|
|
151
|
+
const band = satisfied
|
|
152
|
+
? ""
|
|
153
|
+
: `<form class="t-card-band" method="post" action="/connect/${esc(token)}/secret">
|
|
154
|
+
<input type="hidden" name="name" value="${esc(req.name)}">
|
|
155
|
+
<input class="t-input t-grow" name="value" type="password" placeholder="value" required autocomplete="off" aria-label="value for ${esc(req.name)}">
|
|
156
|
+
<button class="t-btn t-btn--primary">Save</button>
|
|
157
|
+
</form>`;
|
|
158
|
+
return `<div class="t-card">${top}${band}</div>`;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const top = head({
|
|
162
|
+
glyph: glyph(req.connector),
|
|
163
|
+
title: `${req.connector}.${req.trigger}`,
|
|
164
|
+
mono: true,
|
|
165
|
+
kind: "webhook",
|
|
166
|
+
meta: `Register with the provider · needed by ${req.automation}`,
|
|
167
|
+
status: status(satisfied, "action needed", "registered"),
|
|
168
|
+
});
|
|
169
|
+
const band = satisfied
|
|
170
|
+
? ""
|
|
171
|
+
: `<div class="t-card-band t-card-band--stacked">
|
|
172
|
+
<pre class="t-pre">${esc(req.instructions)}</pre>
|
|
173
|
+
<form class="t-form-row" method="post" action="/connect/${esc(token)}/webhook/${esc(req.registrationId)}">
|
|
174
|
+
<input class="t-input t-grow" name="signing_secret" type="password" placeholder="signing secret" required autocomplete="off" aria-label="signing secret">
|
|
175
|
+
<button class="t-btn t-btn--primary">Save and mark registered</button>
|
|
176
|
+
</form>
|
|
177
|
+
</div>`;
|
|
178
|
+
return `<div class="t-card">${top}${band}</div>`;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/* the link page ---------------------------------------------------------- */
|
|
182
|
+
|
|
183
|
+
const COUNT_WORDS = ["Zero", "One", "Two", "Three", "Four", "Five", "Six", "Seven", "Eight", "Nine"];
|
|
184
|
+
|
|
185
|
+
export function linkHeadline(total: number, done: boolean): string {
|
|
186
|
+
if (done) return "All set.";
|
|
187
|
+
const count = COUNT_WORDS[total] ?? String(total);
|
|
188
|
+
return `${count} ${total === 1 ? "thing" : "things"} to connect.`;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Maps the ?done= redirect param to a human notice. */
|
|
192
|
+
export function noticeLabel(done: string): string {
|
|
193
|
+
if (done.startsWith("secret:")) return `Saved ${done.slice("secret:".length)}.`;
|
|
194
|
+
if (done === "webhook") return "Webhook registered.";
|
|
195
|
+
return `Connected ${done}.`;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export function linkPage(o: {
|
|
199
|
+
host: string;
|
|
200
|
+
token: string;
|
|
201
|
+
requirements: RequirementView[];
|
|
202
|
+
completed: boolean;
|
|
203
|
+
notice?: string | undefined;
|
|
204
|
+
}): string {
|
|
205
|
+
const total = o.requirements.length;
|
|
206
|
+
const ready = o.requirements.filter((r) => r.satisfied).length;
|
|
207
|
+
const pending = total - ready;
|
|
208
|
+
const done = o.completed || (total > 0 && pending === 0);
|
|
209
|
+
|
|
210
|
+
const progress =
|
|
211
|
+
total === 0
|
|
212
|
+
? ""
|
|
213
|
+
: `<div class="t-progress t-gap-top-32">
|
|
214
|
+
<div class="t-progress-meta"><span>${ready} of ${total} ready</span><span class="is-remaining">${pending > 0 ? `${pending} pending` : "complete"}</span></div>
|
|
215
|
+
<div class="t-progress-track"><div class="t-progress-fill" style="width:${Math.round((ready / total) * 100)}%"></div></div>
|
|
216
|
+
</div>`;
|
|
217
|
+
|
|
218
|
+
const notice = o.notice
|
|
219
|
+
? `<div class="t-notice t-gap-top-24"><span class="t-dot t-dot--positive"></span>${esc(noticeLabel(o.notice))}</div>`
|
|
220
|
+
: "";
|
|
221
|
+
|
|
222
|
+
const cards = total === 0 ? "" : `<div class="t-stack t-gap-top-32">${o.requirements.map((r) => requirementCard(o.token, r)).join("\n")}</div>`;
|
|
223
|
+
|
|
224
|
+
return shell({
|
|
225
|
+
title: done ? "All set" : "Connect",
|
|
226
|
+
host: o.host,
|
|
227
|
+
chip: { dot: "positive", label: "single-use link" },
|
|
228
|
+
headline: linkHeadline(total, done),
|
|
229
|
+
lead: done
|
|
230
|
+
? "Everything this deploy needs is in place. You can close this page. The agent picks it up from here."
|
|
231
|
+
: "Your agent halted this deploy until these are in place. The agent takes it from there.",
|
|
232
|
+
body: `${notice}${progress}${cards}`,
|
|
233
|
+
securityNote: true,
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/* secondary pages --------------------------------------------------------- */
|
|
238
|
+
|
|
239
|
+
export function oauthAppPage(o: { host: string; token: string; provider: string; connector: string; mode: string; scope: string; endUserId?: string | undefined; callbackUrl: string }): string {
|
|
240
|
+
const top = head({
|
|
241
|
+
glyph: glyph(o.provider),
|
|
242
|
+
title: o.provider,
|
|
243
|
+
kind: "oauth app",
|
|
244
|
+
meta: "Bring your own app: the instance stores the client credentials, encrypted",
|
|
245
|
+
status: status(false, "action needed", "ready"),
|
|
246
|
+
});
|
|
247
|
+
const body = `<div class="t-card t-gap-top-32">${top}
|
|
248
|
+
<div class="t-card-band t-card-band--stacked">
|
|
249
|
+
<span class="t-scopes">callback url</span>
|
|
250
|
+
<pre class="t-pre">${esc(o.callbackUrl)}</pre>
|
|
251
|
+
<form class="t-form-col" method="post" action="/connect/${esc(o.token)}/oauth/app">
|
|
252
|
+
<input type="hidden" name="provider" value="${esc(o.provider)}">
|
|
253
|
+
<input type="hidden" name="connector" value="${esc(o.connector)}">
|
|
254
|
+
<input type="hidden" name="mode" value="${esc(o.mode)}">
|
|
255
|
+
<input type="hidden" name="scope" value="${esc(o.scope)}">
|
|
256
|
+
${o.endUserId ? `<input type="hidden" name="end_user_id" value="${esc(o.endUserId)}">` : ""}
|
|
257
|
+
${fieldInput("client_id", "client id")}
|
|
258
|
+
${fieldInput("client_secret", "client secret")}
|
|
259
|
+
<button class="t-btn t-btn--accent">Connect with OAuth ${ARROW}</button>
|
|
260
|
+
</form>
|
|
261
|
+
</div>
|
|
262
|
+
</div>`;
|
|
263
|
+
return shell({
|
|
264
|
+
title: `OAuth app for ${o.provider}`,
|
|
265
|
+
host: o.host,
|
|
266
|
+
chip: { dot: "warning", label: "setup needed" },
|
|
267
|
+
headline: "One app to register.",
|
|
268
|
+
lead: `This instance has no OAuth app for <code class="t-code">${esc(o.provider)}</code> yet. Create one in the provider's developer console with the callback URL below, then continue to consent.`,
|
|
269
|
+
body,
|
|
270
|
+
securityNote: true,
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
export function messagePage(o: {
|
|
275
|
+
host: string;
|
|
276
|
+
title: string;
|
|
277
|
+
chip: NonNullable<ShellOptions["chip"]>;
|
|
278
|
+
headline: string;
|
|
279
|
+
lead: string;
|
|
280
|
+
pre?: string;
|
|
281
|
+
}): string {
|
|
282
|
+
return shell({
|
|
283
|
+
title: o.title,
|
|
284
|
+
host: o.host,
|
|
285
|
+
chip: o.chip,
|
|
286
|
+
headline: o.headline,
|
|
287
|
+
lead: o.lead,
|
|
288
|
+
body: o.pre ? `<pre class="t-pre t-gap-top-24">${esc(o.pre)}</pre>` : "",
|
|
289
|
+
});
|
|
290
|
+
}
|
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
// The connect pages (ADR-0005): the human's browser is where values enter the system.
|
|
2
|
+
// Single-use links; OAuth consent and secret paste both land directly in the encrypted
|
|
3
|
+
// store; the agent only polls /api/connect-links/:token/status. Plain HTML forms,
|
|
4
|
+
// rendered by connect-view.ts in the @devosurf/tesser-brand system.
|
|
5
|
+
|
|
6
|
+
import { randomBytes } from "node:crypto";
|
|
7
|
+
import { Hono } from "hono";
|
|
8
|
+
import type { Db } from "../db/db.js";
|
|
9
|
+
import type { Broker } from "../broker/broker.js";
|
|
10
|
+
import {
|
|
11
|
+
connectLinkStatus,
|
|
12
|
+
getConnectLink,
|
|
13
|
+
type ConnectionRequirement,
|
|
14
|
+
type ConnectLinkRow,
|
|
15
|
+
} from "../broker/connect.js";
|
|
16
|
+
import { buildAuthorizeUrl, exchangeCode, generatePkce } from "../broker/oauth.js";
|
|
17
|
+
import { hashWebhookSetupToken } from "../gitsync/deploy-keys.js";
|
|
18
|
+
import { esc, linkPage, messagePage, oauthAppPage, shell } from "./connect-view.js";
|
|
19
|
+
|
|
20
|
+
export interface ConnectDeps {
|
|
21
|
+
db: Db;
|
|
22
|
+
broker: Broker;
|
|
23
|
+
baseUrl: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function hostOf(baseUrl: string): string {
|
|
27
|
+
try {
|
|
28
|
+
return new URL(baseUrl).host;
|
|
29
|
+
} catch {
|
|
30
|
+
return baseUrl;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function renderLink(deps: ConnectDeps, link: ConnectLinkRow, notice?: string): Promise<string> {
|
|
35
|
+
const status = await connectLinkStatus(deps.db, deps.broker, link);
|
|
36
|
+
return linkPage({
|
|
37
|
+
host: hostOf(deps.baseUrl),
|
|
38
|
+
token: link.token,
|
|
39
|
+
requirements: status.requirements,
|
|
40
|
+
completed: status.status === "completed",
|
|
41
|
+
notice,
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function createConnectRoutes(deps: ConnectDeps): Hono {
|
|
46
|
+
const app = new Hono();
|
|
47
|
+
const host = hostOf(deps.baseUrl);
|
|
48
|
+
|
|
49
|
+
app.get("/setup/git/:token", async (c) => {
|
|
50
|
+
const tokenHash = hashWebhookSetupToken(c.req.param("token"));
|
|
51
|
+
const { rows } = await deps.db.query<{
|
|
52
|
+
id: string;
|
|
53
|
+
name: string;
|
|
54
|
+
workspace_id: string;
|
|
55
|
+
deploy_key_public: string | null;
|
|
56
|
+
push_webhook_secret_cipher: string | null;
|
|
57
|
+
}>(
|
|
58
|
+
`SELECT id, name, workspace_id, deploy_key_public, push_webhook_secret_cipher
|
|
59
|
+
FROM projects WHERE push_webhook_setup_token_hash=$1`,
|
|
60
|
+
[tokenHash],
|
|
61
|
+
);
|
|
62
|
+
const project = rows[0];
|
|
63
|
+
if (!project || !project.push_webhook_secret_cipher || !project.deploy_key_public) {
|
|
64
|
+
return c.html(
|
|
65
|
+
messagePage({
|
|
66
|
+
host,
|
|
67
|
+
title: "Unknown setup link",
|
|
68
|
+
chip: { dot: "danger", label: "unknown setup" },
|
|
69
|
+
headline: "Unknown setup link.",
|
|
70
|
+
lead: "This Project git setup link does not exist. Ask the agent to run <code class=\"t-code\">tesser link</code> again.",
|
|
71
|
+
}),
|
|
72
|
+
404,
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
const webhookSecret = await deps.broker.decryptValue(
|
|
76
|
+
project.workspace_id,
|
|
77
|
+
project.push_webhook_secret_cipher,
|
|
78
|
+
"project.webhook.signing",
|
|
79
|
+
);
|
|
80
|
+
const webhookUrl = `${deps.baseUrl.replace(/\/$/, "")}/hooks/git/${project.id}/push`;
|
|
81
|
+
return c.html(
|
|
82
|
+
shell({
|
|
83
|
+
title: `Git setup for ${project.name}`,
|
|
84
|
+
host,
|
|
85
|
+
chip: { dot: "warning", label: "human setup" },
|
|
86
|
+
headline: "Configure git deploy access.",
|
|
87
|
+
lead: `Add the deploy key and push webhook below in your git host for Project <code class=\"t-code\">${esc(project.name)}</code>. The private key and webhook signing secret are stored encrypted by this Instance and never returned by the CLI/API JSON.`,
|
|
88
|
+
body: `<div class="t-stack t-gap-top-32">
|
|
89
|
+
<div class="t-card"><div class="t-card-band t-card-band--stacked">
|
|
90
|
+
<span class="t-scopes">deploy key (public)</span>
|
|
91
|
+
<pre class="t-pre">${esc(project.deploy_key_public)}</pre>
|
|
92
|
+
</div></div>
|
|
93
|
+
<div class="t-card"><div class="t-card-band t-card-band--stacked">
|
|
94
|
+
<span class="t-scopes">push webhook url</span>
|
|
95
|
+
<pre class="t-pre">${esc(webhookUrl)}</pre>
|
|
96
|
+
<span class="t-scopes">signing secret</span>
|
|
97
|
+
<pre class="t-pre">${esc(webhookSecret)}</pre>
|
|
98
|
+
<span class="t-scopes">signature header</span>
|
|
99
|
+
<pre class="t-pre">X-Hub-Signature-256: sha256=<hmac-sha256 body with signing secret></pre>
|
|
100
|
+
</div></div>
|
|
101
|
+
</div>`,
|
|
102
|
+
}),
|
|
103
|
+
);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
app.get("/connect/:token", async (c) => {
|
|
107
|
+
const link = await getConnectLink(deps.db, c.req.param("token"));
|
|
108
|
+
if (!link) {
|
|
109
|
+
return c.html(
|
|
110
|
+
messagePage({
|
|
111
|
+
host,
|
|
112
|
+
title: "Unknown link",
|
|
113
|
+
chip: { dot: "danger", label: "unknown link" },
|
|
114
|
+
headline: "Unknown link.",
|
|
115
|
+
lead: "This connect link does not exist.",
|
|
116
|
+
}),
|
|
117
|
+
404,
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
if (link.status === "expired") {
|
|
121
|
+
return c.html(
|
|
122
|
+
messagePage({
|
|
123
|
+
host,
|
|
124
|
+
title: "Link expired",
|
|
125
|
+
chip: { dot: "warning", label: "link expired" },
|
|
126
|
+
headline: "Link expired.",
|
|
127
|
+
lead: `Connect links are single use and short-lived. Ask the agent to mint a new one with <code class="t-code">tesser connect</code>.`,
|
|
128
|
+
}),
|
|
129
|
+
410,
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
const notice = c.req.query("done");
|
|
133
|
+
return c.html(await renderLink(deps, link, notice));
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
app.post("/connect/:token/secret", async (c) => {
|
|
137
|
+
const link = await getConnectLink(deps.db, c.req.param("token"));
|
|
138
|
+
if (!link || link.status !== "pending") return c.text("link not active", 410);
|
|
139
|
+
const form = await c.req.parseBody();
|
|
140
|
+
const name = String(form["name"] ?? "");
|
|
141
|
+
const value = String(form["value"] ?? "");
|
|
142
|
+
if (!link.requirements.some((r) => r.type === "secret" && r.name === name)) {
|
|
143
|
+
return c.text("secret not part of this link", 400);
|
|
144
|
+
}
|
|
145
|
+
if (value.length === 0) return c.text("empty value", 400);
|
|
146
|
+
await deps.broker.setSecret(link.workspace_id, name, value);
|
|
147
|
+
return c.redirect(`/connect/${link.token}?done=secret:${encodeURIComponent(name)}`);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
app.post("/connect/:token/connection", async (c) => {
|
|
151
|
+
const link = await getConnectLink(deps.db, c.req.param("token"));
|
|
152
|
+
if (!link || link.status !== "pending") return c.text("link not active", 410);
|
|
153
|
+
const form = await c.req.parseBody();
|
|
154
|
+
const connector = String(form["connector"] ?? "");
|
|
155
|
+
const modeName = String(form["mode"] ?? "");
|
|
156
|
+
const scope = String(form["scope"] ?? "workspace");
|
|
157
|
+
const req = link.requirements.find(
|
|
158
|
+
(r): r is ConnectionRequirement => r.type === "connection" && r.connector === connector && r.scope === scope,
|
|
159
|
+
);
|
|
160
|
+
const mode = req?.modes.find((m) => m.name === modeName);
|
|
161
|
+
if (!req || !mode || mode.kind === "oauth2") return c.text("unknown connection requirement/mode", 400);
|
|
162
|
+
|
|
163
|
+
const endUserId = req.scope === "per_user" ? String(form["end_user_id"] ?? "").trim() : "";
|
|
164
|
+
if (req.scope === "per_user" && endUserId.length === 0) return c.text("missing end_user_id", 400);
|
|
165
|
+
const fields: Record<string, string> = {};
|
|
166
|
+
for (const f of mode.fields) {
|
|
167
|
+
const v = String(form[`field_${f}`] ?? "");
|
|
168
|
+
if (v.length === 0) return c.text(`missing field ${f}`, 400);
|
|
169
|
+
fields[f] = v;
|
|
170
|
+
}
|
|
171
|
+
const connectionId = await deps.broker.createConnection({
|
|
172
|
+
workspaceId: link.workspace_id,
|
|
173
|
+
connectorId: connector,
|
|
174
|
+
...(req.provider !== undefined ? { provider: req.provider } : {}),
|
|
175
|
+
authMode: modeName,
|
|
176
|
+
scope: req.scope,
|
|
177
|
+
...(req.scope === "per_user" ? { endUserId } : {}),
|
|
178
|
+
label: `via connect link`,
|
|
179
|
+
});
|
|
180
|
+
await deps.broker.setConnectionCredential(connectionId, fields);
|
|
181
|
+
return c.redirect(`/connect/${link.token}?done=${encodeURIComponent(connector)}`);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
app.get("/connect/:token/oauth/start", async (c) => {
|
|
185
|
+
const link = await getConnectLink(deps.db, c.req.param("token"));
|
|
186
|
+
if (!link || link.status !== "pending") return c.text("link not active", 410);
|
|
187
|
+
const connector = c.req.query("connector") ?? "";
|
|
188
|
+
const modeName = c.req.query("mode") ?? "";
|
|
189
|
+
const scope = c.req.query("scope") ?? "workspace";
|
|
190
|
+
const req = link.requirements.find(
|
|
191
|
+
(r): r is ConnectionRequirement => r.type === "connection" && r.connector === connector && r.scope === scope,
|
|
192
|
+
);
|
|
193
|
+
const mode = req?.modes.find((m) => m.name === modeName);
|
|
194
|
+
if (!req || !mode || mode.kind !== "oauth2" || !req.providerFacts?.oauth2) {
|
|
195
|
+
return c.text("unknown oauth requirement", 400);
|
|
196
|
+
}
|
|
197
|
+
const endUserId = req.scope === "per_user" ? (c.req.query("end_user_id") ?? "").trim() : "";
|
|
198
|
+
if (req.scope === "per_user" && endUserId.length === 0) return c.text("missing end_user_id", 400);
|
|
199
|
+
const provider = req.provider ?? req.providerFacts.id;
|
|
200
|
+
const app2 = await deps.broker.getOAuthApp(link.workspace_id, provider);
|
|
201
|
+
if (!app2) {
|
|
202
|
+
// BYO OAuth app (ADR-0005): collect the operator's client id/secret first.
|
|
203
|
+
return c.html(
|
|
204
|
+
oauthAppPage({
|
|
205
|
+
host,
|
|
206
|
+
token: link.token,
|
|
207
|
+
provider,
|
|
208
|
+
connector,
|
|
209
|
+
mode: modeName,
|
|
210
|
+
scope: req.scope,
|
|
211
|
+
...(endUserId ? { endUserId } : {}),
|
|
212
|
+
callbackUrl: `${deps.baseUrl}/oauth/callback`,
|
|
213
|
+
}),
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const connectionId = await deps.broker.createConnection({
|
|
218
|
+
workspaceId: link.workspace_id,
|
|
219
|
+
connectorId: connector,
|
|
220
|
+
provider,
|
|
221
|
+
authMode: modeName,
|
|
222
|
+
scope: req.scope,
|
|
223
|
+
...(req.scope === "per_user" ? { endUserId } : {}),
|
|
224
|
+
label: "via connect link (oauth)",
|
|
225
|
+
});
|
|
226
|
+
const state = `st_${randomBytes(16).toString("hex")}`;
|
|
227
|
+
const facts = req.providerFacts.oauth2;
|
|
228
|
+
const pkce = facts.pkce === false ? undefined : generatePkce();
|
|
229
|
+
const redirectUri = `${deps.baseUrl}/oauth/callback`;
|
|
230
|
+
await deps.db.query(
|
|
231
|
+
`INSERT INTO oauth_states (state, connect_token, connection_id, provider, code_verifier, redirect_uri, expires_at)
|
|
232
|
+
VALUES ($1,$2,$3,$4,$5,$6, now() + interval '15 minutes')`,
|
|
233
|
+
[state, link.token, connectionId, provider, pkce?.verifier ?? null, redirectUri],
|
|
234
|
+
);
|
|
235
|
+
const url = buildAuthorizeUrl({
|
|
236
|
+
facts,
|
|
237
|
+
clientId: app2.clientId,
|
|
238
|
+
redirectUri,
|
|
239
|
+
scopes: mode.scopes ?? [],
|
|
240
|
+
state,
|
|
241
|
+
codeChallenge: pkce?.challenge,
|
|
242
|
+
});
|
|
243
|
+
return c.redirect(url);
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
app.post("/connect/:token/oauth/app", async (c) => {
|
|
247
|
+
const link = await getConnectLink(deps.db, c.req.param("token"));
|
|
248
|
+
if (!link || link.status !== "pending") return c.text("link not active", 410);
|
|
249
|
+
const form = await c.req.parseBody();
|
|
250
|
+
const provider = String(form["provider"] ?? "");
|
|
251
|
+
const clientId = String(form["client_id"] ?? "");
|
|
252
|
+
const clientSecret = String(form["client_secret"] ?? "");
|
|
253
|
+
if (!provider || !clientId || !clientSecret) return c.text("missing fields", 400);
|
|
254
|
+
await deps.broker.setOAuthApp(link.workspace_id, provider, clientId, clientSecret);
|
|
255
|
+
const params = new URLSearchParams({
|
|
256
|
+
connector: String(form["connector"]),
|
|
257
|
+
mode: String(form["mode"]),
|
|
258
|
+
scope: String(form["scope"] ?? "workspace"),
|
|
259
|
+
});
|
|
260
|
+
const endUserId = String(form["end_user_id"] ?? "").trim();
|
|
261
|
+
if (endUserId) params.set("end_user_id", endUserId);
|
|
262
|
+
return c.redirect(`/connect/${link.token}/oauth/start?${params}`);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
app.get("/oauth/callback", async (c) => {
|
|
266
|
+
const oauthError = (lead: string, pre?: string): string =>
|
|
267
|
+
messagePage({
|
|
268
|
+
host,
|
|
269
|
+
title: "OAuth error",
|
|
270
|
+
chip: { dot: "danger", label: "oauth error" },
|
|
271
|
+
headline: "OAuth error.",
|
|
272
|
+
lead,
|
|
273
|
+
...(pre !== undefined ? { pre } : {}),
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
const state = c.req.query("state") ?? "";
|
|
277
|
+
const code = c.req.query("code") ?? "";
|
|
278
|
+
const { rows } = await deps.db.query<{
|
|
279
|
+
state: string;
|
|
280
|
+
connect_token: string | null;
|
|
281
|
+
connection_id: string;
|
|
282
|
+
provider: string;
|
|
283
|
+
code_verifier: string | null;
|
|
284
|
+
redirect_uri: string;
|
|
285
|
+
expires_at: string;
|
|
286
|
+
}>(`SELECT * FROM oauth_states WHERE state=$1`, [state]);
|
|
287
|
+
const st = rows[0];
|
|
288
|
+
if (!st || new Date(String(st.expires_at)).getTime() < Date.now()) {
|
|
289
|
+
return c.html(oauthError("State expired or unknown. Restart from the connect page."), 400);
|
|
290
|
+
}
|
|
291
|
+
await deps.db.query(`DELETE FROM oauth_states WHERE state=$1`, [state]); // single-use
|
|
292
|
+
if (!code) return c.html(oauthError(`The provider returned: ${esc(c.req.query("error") ?? "consent denied")}.`), 400);
|
|
293
|
+
|
|
294
|
+
const link = st.connect_token ? await getConnectLink(deps.db, st.connect_token) : null;
|
|
295
|
+
const req = link?.requirements.find(
|
|
296
|
+
(r): r is ConnectionRequirement =>
|
|
297
|
+
r.type === "connection" && (r.provider ?? r.providerFacts?.id) === st.provider,
|
|
298
|
+
);
|
|
299
|
+
const facts = req?.providerFacts?.oauth2;
|
|
300
|
+
if (!link || !facts) return c.html(oauthError("Connect link no longer valid."), 400);
|
|
301
|
+
|
|
302
|
+
const app2 = await deps.broker.getOAuthApp(link.workspace_id, st.provider);
|
|
303
|
+
if (!app2) return c.html(oauthError("OAuth app vanished mid-flow."), 400);
|
|
304
|
+
|
|
305
|
+
try {
|
|
306
|
+
const tokens = await exchangeCode({
|
|
307
|
+
facts,
|
|
308
|
+
clientId: app2.clientId,
|
|
309
|
+
clientSecret: app2.clientSecret,
|
|
310
|
+
code,
|
|
311
|
+
redirectUri: st.redirect_uri,
|
|
312
|
+
codeVerifier: st.code_verifier ?? undefined,
|
|
313
|
+
});
|
|
314
|
+
await deps.broker.setConnectionCredential(
|
|
315
|
+
st.connection_id,
|
|
316
|
+
{
|
|
317
|
+
access_token: tokens.accessToken,
|
|
318
|
+
...(tokens.refreshToken !== undefined ? { refresh_token: tokens.refreshToken } : {}),
|
|
319
|
+
},
|
|
320
|
+
{
|
|
321
|
+
...(tokens.expiresAt !== undefined ? { expiresAt: tokens.expiresAt } : {}),
|
|
322
|
+
...(tokens.scope !== undefined ? { scope: tokens.scope } : {}),
|
|
323
|
+
},
|
|
324
|
+
);
|
|
325
|
+
} catch (err) {
|
|
326
|
+
return c.html(oauthError("Token exchange failed.", (err as Error).message), 502);
|
|
327
|
+
}
|
|
328
|
+
return c.redirect(`/connect/${link.token}?done=${encodeURIComponent(req!.connector)}`);
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
// Manual webhook registration completion (ADR-0013 manual mode).
|
|
332
|
+
app.post("/connect/:token/webhook/:registrationId", async (c) => {
|
|
333
|
+
const link = await getConnectLink(deps.db, c.req.param("token"));
|
|
334
|
+
if (!link || link.status !== "pending") return c.text("link not active", 410);
|
|
335
|
+
const regId = c.req.param("registrationId");
|
|
336
|
+
if (!link.requirements.some((r) => r.type === "webhook-manual" && r.registrationId === regId)) {
|
|
337
|
+
return c.text("registration not part of this link", 400);
|
|
338
|
+
}
|
|
339
|
+
const form = await c.req.parseBody();
|
|
340
|
+
const secret = String(form["signing_secret"] ?? "");
|
|
341
|
+
if (secret.length === 0) return c.text("empty signing secret", 400);
|
|
342
|
+
const cipher = await deps.broker.encryptValue(link.workspace_id, secret, "webhook.signing");
|
|
343
|
+
await deps.db.query(
|
|
344
|
+
`UPDATE webhook_registrations SET signing_secret_cipher=$2, status='registered', updated_at=now() WHERE id=$1`,
|
|
345
|
+
[regId, cipher],
|
|
346
|
+
);
|
|
347
|
+
return c.redirect(`/connect/${link.token}?done=webhook`);
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
return app;
|
|
351
|
+
}
|