ndomo 0.1.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/.bun-version +1 -0
- package/.dockerignore +79 -0
- package/.editorconfig +18 -0
- package/.env.example +19 -0
- package/.github/CODEOWNERS +8 -0
- package/.github/ISSUE_TEMPLATE/bug_report.yml +62 -0
- package/.github/ISSUE_TEMPLATE/config.yml +2 -0
- package/.github/ISSUE_TEMPLATE/feature_request.yml +34 -0
- package/.github/dependabot.yml +36 -0
- package/.github/pull_request_template.md +24 -0
- package/.github/release.yml +30 -0
- package/.github/workflows/gitleaks.yml +28 -0
- package/.github/workflows/release-please.yml +27 -0
- package/.github/workflows/smoke.yml +29 -0
- package/.husky/commit-msg +1 -0
- package/CHANGELOG.md +114 -0
- package/Dockerfile +32 -0
- package/README.es.md +174 -0
- package/README.md +187 -0
- package/agents/chronicler.md +98 -0
- package/agents/ci-smith.md +136 -0
- package/agents/craftsman.md +341 -0
- package/agents/deploy-smith.md +138 -0
- package/agents/foreman.md +377 -0
- package/agents/go-smith.md +164 -0
- package/agents/guild.md +188 -0
- package/agents/inspector.md +83 -0
- package/agents/js-smith.md +127 -0
- package/agents/ops-scout.md +173 -0
- package/agents/painter.md +200 -0
- package/agents/python-smith.md +120 -0
- package/agents/ranger.md +307 -0
- package/agents/release-smith.md +165 -0
- package/agents/rust-smith.md +159 -0
- package/agents/sage.md +178 -0
- package/agents/scout.md +144 -0
- package/agents/scribe.md +156 -0
- package/agents/smith.md +201 -0
- package/agents/vue-smith.md +155 -0
- package/agents/warden.md +216 -0
- package/agents/zig-smith.md +156 -0
- package/bin/ndomo-analyses.ts +4 -0
- package/bin/ndomo-status.ts +4 -0
- package/biome.json +57 -0
- package/bun.lock +514 -0
- package/commitlint.config.js +3 -0
- package/config/ndomo.config.json +258 -0
- package/config/ndomo.schema.json +166 -0
- package/docs/agents.md +375 -0
- package/docs/bugs/plan-create-orphan-fk.md +131 -0
- package/docs/bugs/task_create_batch-order-index-collision.md +158 -0
- package/docs/configuration.md +276 -0
- package/docs/database.md +364 -0
- package/docs/features/feature-flexible-builder-v1.md +724 -0
- package/docs/features/feature-flexible-builder-v2.md +882 -0
- package/docs/features/feature-flexible-builder.md +974 -0
- package/docs/http-server.md +244 -0
- package/docs/installation.md +259 -0
- package/docs/integrations.md +129 -0
- package/docs/operations/anti-pattern-sub-agent-verify-2026-06-21.md +32 -0
- package/docs/operations/audit-v1.md +417 -0
- package/docs/operations/audit-v2.md +197 -0
- package/docs/operations/audit-v3.md +306 -0
- package/docs/operations/db-optimize-foundations.md +123 -0
- package/docs/operations/verify-gate-architecture.md +82 -0
- package/docs/workflows.md +448 -0
- package/opencode.json +5 -0
- package/package.json +65 -0
- package/release-please-config.json +11 -0
- package/scripts/dev-bust-cache.sh +164 -0
- package/scripts/install.sh +688 -0
- package/scripts/smoke-e2e.ts +704 -0
- package/scripts/smoke-hot.ts +417 -0
- package/scripts/smoke-http.sh +228 -0
- package/scripts/smoke-v4.ts +256 -0
- package/scripts/smoke-v5.ts +397 -0
- package/scripts/smoke.sh +9 -0
- package/scripts/uninstall.sh +224 -0
- package/skills/api-security-best-practices/SKILL.md +915 -0
- package/skills/bash-scripting/SKILL.md +201 -0
- package/skills/bun/SKILL.md +313 -0
- package/skills/cavecrew/SKILL.md +82 -0
- package/skills/caveman/SKILL.md +74 -0
- package/skills/caveman-review/README.md +33 -0
- package/skills/caveman-review/SKILL.md +55 -0
- package/skills/find-skills/SKILL.md +142 -0
- package/skills/frontend-design/LICENSE.txt +177 -0
- package/skills/frontend-design/SKILL.md +55 -0
- package/skills/golang-patterns/SKILL.md +674 -0
- package/skills/golang-security/SKILL.md +185 -0
- package/skills/golang-security/evals/evals.json +595 -0
- package/skills/golang-security/references/architecture.md +268 -0
- package/skills/golang-security/references/checklist.md +80 -0
- package/skills/golang-security/references/cookies.md +200 -0
- package/skills/golang-security/references/cryptography.md +424 -0
- package/skills/golang-security/references/filesystem.md +285 -0
- package/skills/golang-security/references/injection.md +315 -0
- package/skills/golang-security/references/logging.md +163 -0
- package/skills/golang-security/references/memory-safety.md +241 -0
- package/skills/golang-security/references/network.md +253 -0
- package/skills/golang-security/references/secrets.md +189 -0
- package/skills/golang-security/references/third-party.md +159 -0
- package/skills/golang-security/references/threat-modeling.md +189 -0
- package/skills/golang-testing/SKILL.md +720 -0
- package/skills/grill-me/SKILL.md +7 -0
- package/skills/javascript-testing-patterns/SKILL.md +537 -0
- package/skills/javascript-testing-patterns/references/advanced-testing-patterns.md +513 -0
- package/skills/modern-javascript-patterns/SKILL.md +43 -0
- package/skills/modern-javascript-patterns/references/advanced-patterns.md +487 -0
- package/skills/modern-javascript-patterns/references/details.md +457 -0
- package/skills/python-anti-patterns/SKILL.md +349 -0
- package/skills/python-design-patterns/SKILL.md +85 -0
- package/skills/python-design-patterns/references/details.md +353 -0
- package/skills/python-error-handling/SKILL.md +193 -0
- package/skills/python-error-handling/references/details.md +171 -0
- package/skills/python-testing-patterns/SKILL.md +278 -0
- package/skills/python-testing-patterns/references/advanced-patterns.md +411 -0
- package/skills/python-testing-patterns/references/details.md +349 -0
- package/skills/rust-patterns/SKILL.md +500 -0
- package/skills/rust-testing/SKILL.md +501 -0
- package/skills/security-review/SKILL.md +504 -0
- package/skills/security-review/cloud-infrastructure-security.md +361 -0
- package/skills/vue-best-practices/SKILL.md +154 -0
- package/skills/vue-best-practices/references/animation-class-based-technique.md +254 -0
- package/skills/vue-best-practices/references/animation-state-driven-technique.md +291 -0
- package/skills/vue-best-practices/references/component-async.md +97 -0
- package/skills/vue-best-practices/references/component-data-flow.md +307 -0
- package/skills/vue-best-practices/references/component-fallthrough-attrs.md +174 -0
- package/skills/vue-best-practices/references/component-keep-alive.md +137 -0
- package/skills/vue-best-practices/references/component-slots.md +216 -0
- package/skills/vue-best-practices/references/component-suspense.md +228 -0
- package/skills/vue-best-practices/references/component-teleport.md +108 -0
- package/skills/vue-best-practices/references/component-transition-group.md +128 -0
- package/skills/vue-best-practices/references/component-transition.md +125 -0
- package/skills/vue-best-practices/references/composables.md +290 -0
- package/skills/vue-best-practices/references/directives.md +162 -0
- package/skills/vue-best-practices/references/perf-avoid-component-abstraction-in-lists.md +159 -0
- package/skills/vue-best-practices/references/perf-v-once-v-memo-directives.md +182 -0
- package/skills/vue-best-practices/references/perf-virtualize-large-lists.md +187 -0
- package/skills/vue-best-practices/references/plugins.md +166 -0
- package/skills/vue-best-practices/references/reactivity.md +344 -0
- package/skills/vue-best-practices/references/render-functions.md +201 -0
- package/skills/vue-best-practices/references/sfc.md +310 -0
- package/skills/vue-best-practices/references/state-management.md +135 -0
- package/skills/vue-best-practices/references/updated-hook-performance.md +187 -0
- package/skills/vue-pinia-best-practices/SKILL.md +21 -0
- package/skills/vue-pinia-best-practices/reference/pinia-no-active-pinia-error.md +248 -0
- package/skills/vue-pinia-best-practices/reference/pinia-setup-store-return-all-state.md +227 -0
- package/skills/vue-pinia-best-practices/reference/pinia-store-destructuring-breaks-reactivity.md +193 -0
- package/skills/vue-pinia-best-practices/reference/state-url-for-ephemeral-filters.md +238 -0
- package/skills/vue-pinia-best-practices/reference/state-use-pinia-for-large-apps.md +262 -0
- package/skills/vue-pinia-best-practices/reference/store-method-binding-parentheses.md +191 -0
- package/skills/zig-0.16/SKILL.md +840 -0
- package/skills/zig-0.16/scripts/check-zig-version.sh +21 -0
- package/src/cli/analyses.ts +280 -0
- package/src/cli/index.ts +108 -0
- package/src/cli/serve.ts +192 -0
- package/src/cli/smoke.ts +131 -0
- package/src/cli/status.test.ts +204 -0
- package/src/cli/status.ts +263 -0
- package/src/cli/vacuum.test.ts +82 -0
- package/src/cli/vacuum.ts +96 -0
- package/src/config/schema.test.ts +88 -0
- package/src/config/schema.ts +64 -0
- package/src/db/analyses-migration.test.ts +210 -0
- package/src/db/analyses.test.ts +466 -0
- package/src/db/analyses.ts +375 -0
- package/src/db/auto-checkpoint.ts +131 -0
- package/src/db/client.test.ts +129 -0
- package/src/db/client.ts +55 -0
- package/src/db/fts-escape.ts +20 -0
- package/src/db/incidents.test.ts +201 -0
- package/src/db/incidents.ts +93 -0
- package/src/db/index.ts +86 -0
- package/src/db/migrations-v13.test.ts +141 -0
- package/src/db/migrations-v8.test.ts +301 -0
- package/src/db/migrations.ts +147 -0
- package/src/db/plan-archive.test.ts +180 -0
- package/src/db/plan-archive.ts +274 -0
- package/src/db/plan-create.test.ts +276 -0
- package/src/db/plan-create.ts +78 -0
- package/src/db/plan-files.test.ts +289 -0
- package/src/db/plan-update-status.ts +287 -0
- package/src/db/plans.test.ts +490 -0
- package/src/db/plans.ts +534 -0
- package/src/db/resolve-project-dir.test.ts +143 -0
- package/src/db/resolve-project-dir.ts +75 -0
- package/src/db/rollbacks.test.ts +150 -0
- package/src/db/rollbacks.ts +67 -0
- package/src/db/schema.ts +907 -0
- package/src/db/sessions.test.ts +80 -0
- package/src/db/sessions.ts +135 -0
- package/src/db/shutdown.test.ts +147 -0
- package/src/db/shutdown.ts +45 -0
- package/src/db/tasks.test.ts +921 -0
- package/src/db/tasks.ts +747 -0
- package/src/db/types.ts +619 -0
- package/src/http/__tests__/auth.test.ts +196 -0
- package/src/http/__tests__/routes.test.ts +465 -0
- package/src/http/__tests__/sse.test.ts +317 -0
- package/src/http/auth.ts +72 -0
- package/src/http/middleware/cors.ts +53 -0
- package/src/http/middleware/security-headers.ts +21 -0
- package/src/http/routes/events.ts +112 -0
- package/src/http/routes/health.ts +51 -0
- package/src/http/routes/plans.ts +66 -0
- package/src/http/routes/sessions.ts +50 -0
- package/src/http/routes/tasks.ts +60 -0
- package/src/http/server.ts +95 -0
- package/src/http/sse.ts +116 -0
- package/src/index.ts +37 -0
- package/src/lib.ts +65 -0
- package/src/mem/scoped.ts +65 -0
- package/src/orchestrator/background.test.ts +268 -0
- package/src/orchestrator/background.ts +293 -0
- package/src/orchestrator/memory-hook.ts +182 -0
- package/src/orchestrator/reconciler.ts +123 -0
- package/src/orchestrator/scheduler.test.ts +300 -0
- package/src/orchestrator/scheduler.ts +243 -0
- package/src/plugin.test.ts +2574 -0
- package/src/plugin.ts +1690 -0
- package/src/sdk/client.ts +66 -0
- package/src/worktrees/manager.ts +236 -0
- package/src/worktrees/state.ts +87 -0
- package/tests/integration/ranger-flow.test.ts +257 -0
- package/tools/analysis_archive.ts +28 -0
- package/tools/analysis_create.ts +55 -0
- package/tools/analysis_get.ts +33 -0
- package/tools/analysis_link_plan.ts +44 -0
- package/tools/analysis_list.ts +48 -0
- package/tools/analysis_search.ts +36 -0
- package/tools/analysis_update.ts +44 -0
- package/tools/plan_approve.ts +31 -0
- package/tools/plan_create.ts +58 -0
- package/tools/plan_get.ts +40 -0
- package/tools/plan_list.ts +37 -0
- package/tools/plan_search.ts +34 -0
- package/tools/plan_update_status.ts +71 -0
- package/tools/session_checkpoint.ts +31 -0
- package/tools/session_end.ts +26 -0
- package/tools/session_start.ts +43 -0
- package/tools/task_create_batch.ts +70 -0
- package/tools/task_list.ts +35 -0
- package/tools/task_next_for_agent.ts +30 -0
- package/tools/task_search.ts +34 -0
- package/tools/task_update_status.ts +37 -0
- package/tsconfig.json +31 -0
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for SSE events route (src/http/routes/events.ts) + sse helpers.
|
|
3
|
+
*
|
|
4
|
+
* Validates:
|
|
5
|
+
* - formatSseEvent / formatKeepalive (unit)
|
|
6
|
+
* - GET /api/events → 200 with Content-Type: text/event-stream
|
|
7
|
+
* - Events streamed as `data: {json}\n\n` format
|
|
8
|
+
* - Cleanup on client disconnect (abort signal)
|
|
9
|
+
* - Type filter via ?types=session.idle,session.error
|
|
10
|
+
* - 503 when SDK client is null
|
|
11
|
+
* - Auth required
|
|
12
|
+
*
|
|
13
|
+
* Mocks SDK client with a simple async generator.
|
|
14
|
+
* Uses Elysia app.handle(new Request(...)) pattern.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { Database } from "bun:sqlite";
|
|
18
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
19
|
+
import type { HttpConfig } from "../../config/schema.ts";
|
|
20
|
+
import { runMigrations } from "../../db/migrations.ts";
|
|
21
|
+
import { buildHttpServer } from "../server.ts";
|
|
22
|
+
import { formatKeepalive, formatSseEvent } from "../sse.ts";
|
|
23
|
+
|
|
24
|
+
let db: Database;
|
|
25
|
+
|
|
26
|
+
const AUTH_CONFIG: HttpConfig = {
|
|
27
|
+
enabled: true,
|
|
28
|
+
port: 4097,
|
|
29
|
+
cors: { origins: ["*"] },
|
|
30
|
+
auth: { required: true },
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
let savedPassword: string | undefined;
|
|
34
|
+
|
|
35
|
+
function basicAuthHeader(password: string): string {
|
|
36
|
+
const encoded = Buffer.from(`user:${password}`).toString("base64");
|
|
37
|
+
return `Basic ${encoded}`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Create a mock SDK client that yields events from a provided array.
|
|
42
|
+
* Returns a minimal object compatible with OpencodeClient.event.subscribe().
|
|
43
|
+
*/
|
|
44
|
+
function mockSdkClient(events: Array<{ type?: string; data?: unknown }>) {
|
|
45
|
+
return {
|
|
46
|
+
event: {
|
|
47
|
+
subscribe: async () => ({
|
|
48
|
+
stream: (async function* () {
|
|
49
|
+
for (const event of events) {
|
|
50
|
+
yield event;
|
|
51
|
+
}
|
|
52
|
+
})(),
|
|
53
|
+
}),
|
|
54
|
+
},
|
|
55
|
+
} as unknown as import("@opencode-ai/sdk/client").OpencodeClient;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Create a mock SDK client that throws on subscribe.
|
|
60
|
+
*/
|
|
61
|
+
function mockFailingSdkClient(errorMessage = "SDK connection failed") {
|
|
62
|
+
return {
|
|
63
|
+
event: {
|
|
64
|
+
subscribe: async () => {
|
|
65
|
+
throw new Error(errorMessage);
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
} as unknown as import("@opencode-ai/sdk/client").OpencodeClient;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
beforeEach(() => {
|
|
72
|
+
savedPassword = process.env.OPENCODE_SERVER_PASSWORD;
|
|
73
|
+
process.env.OPENCODE_SERVER_PASSWORD = "test-password";
|
|
74
|
+
db = new Database(":memory:");
|
|
75
|
+
db.exec("PRAGMA foreign_keys = ON");
|
|
76
|
+
runMigrations(db);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
afterEach(() => {
|
|
80
|
+
if (savedPassword === undefined) {
|
|
81
|
+
delete process.env.OPENCODE_SERVER_PASSWORD;
|
|
82
|
+
} else {
|
|
83
|
+
process.env.OPENCODE_SERVER_PASSWORD = savedPassword;
|
|
84
|
+
}
|
|
85
|
+
db.close();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
// ─── Unit tests for sse helpers ─────────────────────────────────────────────
|
|
89
|
+
|
|
90
|
+
describe("formatSseEvent", () => {
|
|
91
|
+
test("formats data-only event", () => {
|
|
92
|
+
const result = formatSseEvent({ data: { hello: "world" } });
|
|
93
|
+
expect(result).toBe('data: {"hello":"world"}\n\n');
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("formats event with name", () => {
|
|
97
|
+
const result = formatSseEvent({ eventName: "message", data: { key: 1 } });
|
|
98
|
+
expect(result).toBe('event: message\ndata: {"key":1}\n\n');
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("formats event with id", () => {
|
|
102
|
+
const result = formatSseEvent({ data: "test", id: "42" });
|
|
103
|
+
expect(result).toBe('id: 42\ndata: "test"\n\n');
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("formats event with all fields", () => {
|
|
107
|
+
const result = formatSseEvent({
|
|
108
|
+
eventName: "update",
|
|
109
|
+
data: { value: true },
|
|
110
|
+
id: "100",
|
|
111
|
+
});
|
|
112
|
+
expect(result).toBe('event: update\nid: 100\ndata: {"value":true}\n\n');
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("multi-line data splits into multiple data: lines", () => {
|
|
116
|
+
// JSON with newlines would split — but JSON.stringify doesn't produce
|
|
117
|
+
// newlines for flat objects. Test with string containing newline.
|
|
118
|
+
const result = formatSseEvent({ data: "line1\nline2" });
|
|
119
|
+
expect(result).toBe('data: "line1\\nline2"\n\n');
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
describe("formatKeepalive", () => {
|
|
124
|
+
test("returns SSE keepalive comment", () => {
|
|
125
|
+
const result = formatKeepalive();
|
|
126
|
+
expect(result).toBe(": keepalive\n\n\n");
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
// ─── Integration tests for /api/events ──────────────────────────────────────
|
|
131
|
+
|
|
132
|
+
describe("GET /api/events — SSE stream", () => {
|
|
133
|
+
test("returns 200 with correct SSE headers", async () => {
|
|
134
|
+
const sdkClient = mockSdkClient([]);
|
|
135
|
+
const { app } = await buildHttpServer({
|
|
136
|
+
db,
|
|
137
|
+
httpConfig: AUTH_CONFIG,
|
|
138
|
+
sdkClient,
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
const req = new Request("http://localhost/api/events", {
|
|
142
|
+
headers: { Authorization: basicAuthHeader("test-password") },
|
|
143
|
+
});
|
|
144
|
+
const res = await app.handle(req);
|
|
145
|
+
|
|
146
|
+
expect(res.status).toBe(200);
|
|
147
|
+
expect(res.headers.get("Content-Type")).toBe("text/event-stream");
|
|
148
|
+
expect(res.headers.get("Cache-Control")).toContain("no-cache");
|
|
149
|
+
expect(res.headers.get("Connection")).toBe("keep-alive");
|
|
150
|
+
expect(res.headers.get("X-Accel-Buffering")).toBe("no");
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("streams hello event on connection", async () => {
|
|
154
|
+
const sdkClient = mockSdkClient([]);
|
|
155
|
+
const { app } = await buildHttpServer({
|
|
156
|
+
db,
|
|
157
|
+
httpConfig: AUTH_CONFIG,
|
|
158
|
+
sdkClient,
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
const req = new Request("http://localhost/api/events", {
|
|
162
|
+
headers: { Authorization: basicAuthHeader("test-password") },
|
|
163
|
+
});
|
|
164
|
+
const res = await app.handle(req);
|
|
165
|
+
|
|
166
|
+
expect(res.body).not.toBeNull();
|
|
167
|
+
const reader = res.body!.getReader();
|
|
168
|
+
const decoder = new TextDecoder();
|
|
169
|
+
|
|
170
|
+
// Read first chunk — should contain hello event
|
|
171
|
+
const { value } = await reader.read();
|
|
172
|
+
const chunk = decoder.decode(value);
|
|
173
|
+
expect(chunk).toContain("event: hello");
|
|
174
|
+
expect(chunk).toContain('"timestamp"');
|
|
175
|
+
|
|
176
|
+
reader.cancel();
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test("streams SDK events as SSE data", async () => {
|
|
180
|
+
const sdkClient = mockSdkClient([
|
|
181
|
+
{ type: "session.idle", data: { sessionId: "ses_1" } },
|
|
182
|
+
{ type: "session.error", data: { error: "timeout" } },
|
|
183
|
+
]);
|
|
184
|
+
const { app } = await buildHttpServer({
|
|
185
|
+
db,
|
|
186
|
+
httpConfig: AUTH_CONFIG,
|
|
187
|
+
sdkClient,
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
const req = new Request("http://localhost/api/events", {
|
|
191
|
+
headers: { Authorization: basicAuthHeader("test-password") },
|
|
192
|
+
});
|
|
193
|
+
const res = await app.handle(req);
|
|
194
|
+
|
|
195
|
+
const reader = res.body!.getReader();
|
|
196
|
+
const decoder = new TextDecoder();
|
|
197
|
+
|
|
198
|
+
// Read all chunks until stream ends
|
|
199
|
+
const chunks: string[] = [];
|
|
200
|
+
while (true) {
|
|
201
|
+
const { done, value } = await reader.read();
|
|
202
|
+
if (done) break;
|
|
203
|
+
chunks.push(decoder.decode(value));
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const fullOutput = chunks.join("");
|
|
207
|
+
|
|
208
|
+
// Should contain hello event
|
|
209
|
+
expect(fullOutput).toContain("event: hello");
|
|
210
|
+
|
|
211
|
+
// Should contain the SDK events
|
|
212
|
+
expect(fullOutput).toContain("event: session.idle");
|
|
213
|
+
expect(fullOutput).toContain("event: session.error");
|
|
214
|
+
expect(fullOutput).toContain('"sessionId":"ses_1"');
|
|
215
|
+
expect(fullOutput).toContain('"error":"timeout"');
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test("type filter passes only matching events", async () => {
|
|
219
|
+
const sdkClient = mockSdkClient([
|
|
220
|
+
{ type: "session.idle", data: { sessionId: "ses_1" } },
|
|
221
|
+
{ type: "session.error", data: { error: "timeout" } },
|
|
222
|
+
{ type: "task.complete", data: { taskId: "t_1" } },
|
|
223
|
+
]);
|
|
224
|
+
const { app } = await buildHttpServer({
|
|
225
|
+
db,
|
|
226
|
+
httpConfig: AUTH_CONFIG,
|
|
227
|
+
sdkClient,
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
const req = new Request("http://localhost/api/events?types=session.idle,session.error", {
|
|
231
|
+
headers: { Authorization: basicAuthHeader("test-password") },
|
|
232
|
+
});
|
|
233
|
+
const res = await app.handle(req);
|
|
234
|
+
|
|
235
|
+
const reader = res.body!.getReader();
|
|
236
|
+
const decoder = new TextDecoder();
|
|
237
|
+
|
|
238
|
+
const chunks: string[] = [];
|
|
239
|
+
while (true) {
|
|
240
|
+
const { done, value } = await reader.read();
|
|
241
|
+
if (done) break;
|
|
242
|
+
chunks.push(decoder.decode(value));
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const fullOutput = chunks.join("");
|
|
246
|
+
|
|
247
|
+
// Should contain filtered events
|
|
248
|
+
expect(fullOutput).toContain("event: session.idle");
|
|
249
|
+
expect(fullOutput).toContain("event: session.error");
|
|
250
|
+
|
|
251
|
+
// Should NOT contain non-matching event
|
|
252
|
+
expect(fullOutput).not.toContain("task.complete");
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
test("503 when SDK client is not provided", async () => {
|
|
256
|
+
// Omit sdkClient entirely (not undefined — exactOptionalPropertyTypes)
|
|
257
|
+
const { app } = await buildHttpServer({
|
|
258
|
+
db,
|
|
259
|
+
httpConfig: AUTH_CONFIG,
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
const req = new Request("http://localhost/api/events", {
|
|
263
|
+
headers: { Authorization: basicAuthHeader("test-password") },
|
|
264
|
+
});
|
|
265
|
+
const res = await app.handle(req);
|
|
266
|
+
|
|
267
|
+
expect(res.status).toBe(503);
|
|
268
|
+
const body = (await res.json()) as { error: string };
|
|
269
|
+
expect(body.error).toBe("sdk_unavailable");
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
test("requires auth → 401 without credentials", async () => {
|
|
273
|
+
const sdkClient = mockSdkClient([]);
|
|
274
|
+
const { app } = await buildHttpServer({
|
|
275
|
+
db,
|
|
276
|
+
httpConfig: AUTH_CONFIG,
|
|
277
|
+
sdkClient,
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
const res = await app.handle(new Request("http://localhost/api/events"));
|
|
281
|
+
expect(res.status).toBe(401);
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
test("SDK subscribe error → streams error event then closes", async () => {
|
|
285
|
+
const sdkClient = mockFailingSdkClient("connection refused");
|
|
286
|
+
const { app } = await buildHttpServer({
|
|
287
|
+
db,
|
|
288
|
+
httpConfig: AUTH_CONFIG,
|
|
289
|
+
sdkClient,
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
const req = new Request("http://localhost/api/events", {
|
|
293
|
+
headers: { Authorization: basicAuthHeader("test-password") },
|
|
294
|
+
});
|
|
295
|
+
const res = await app.handle(req);
|
|
296
|
+
|
|
297
|
+
const reader = res.body!.getReader();
|
|
298
|
+
const decoder = new TextDecoder();
|
|
299
|
+
|
|
300
|
+
const chunks: string[] = [];
|
|
301
|
+
while (true) {
|
|
302
|
+
const { done, value } = await reader.read();
|
|
303
|
+
if (done) break;
|
|
304
|
+
chunks.push(decoder.decode(value));
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const fullOutput = chunks.join("");
|
|
308
|
+
|
|
309
|
+
// Should contain hello event first
|
|
310
|
+
expect(fullOutput).toContain("event: hello");
|
|
311
|
+
|
|
312
|
+
// Should contain error event with SDK error message
|
|
313
|
+
expect(fullOutput).toContain("event: error");
|
|
314
|
+
expect(fullOutput).toContain("connection refused");
|
|
315
|
+
expect(fullOutput).toContain("sdk_subscribe_failed");
|
|
316
|
+
});
|
|
317
|
+
});
|
package/src/http/auth.ts
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// ─── HTTP Basic Auth Middleware ───────────────────────────────────────────────
|
|
2
|
+
/**
|
|
3
|
+
* HTTP Basic authentication middleware.
|
|
4
|
+
*
|
|
5
|
+
* Reads OPENCODE_SERVER_PASSWORD from env directly (not via HttpConfig).
|
|
6
|
+
* Uses timing-safe comparison to prevent timing attacks.
|
|
7
|
+
*
|
|
8
|
+
* Uses onRequest so the hook propagates across Elysia .use() boundaries.
|
|
9
|
+
* Exempts /health from auth (public liveness probe).
|
|
10
|
+
*
|
|
11
|
+
* Behavior:
|
|
12
|
+
* - auth.required === false → skip entirely
|
|
13
|
+
* - /health path → skip (always public)
|
|
14
|
+
* - Password unset/empty → 503 auth_not_configured
|
|
15
|
+
* - Missing/malformed Authorization → 401 + WWW-Authenticate
|
|
16
|
+
* - Wrong password → 401 + WWW-Authenticate
|
|
17
|
+
* - Correct → continue (no return value)
|
|
18
|
+
*/
|
|
19
|
+
import { timingSafeEqual } from "node:crypto";
|
|
20
|
+
import { Elysia } from "elysia";
|
|
21
|
+
import type { HttpConfig } from "../config/schema.ts";
|
|
22
|
+
|
|
23
|
+
function constantTimeEqual(a: string, b: string): boolean {
|
|
24
|
+
if (a.length !== b.length) return false;
|
|
25
|
+
const bufA = Buffer.from(a);
|
|
26
|
+
const bufB = Buffer.from(b);
|
|
27
|
+
return timingSafeEqual(bufA, bufB);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function httpBasicAuth(httpConfig: HttpConfig) {
|
|
31
|
+
return new Elysia({ name: "http-basic-auth" }).onRequest(({ request, set }) => {
|
|
32
|
+
// Skip if auth not required
|
|
33
|
+
if (!httpConfig.auth.required) return;
|
|
34
|
+
|
|
35
|
+
// Exempt /health — public liveness probe
|
|
36
|
+
const url = new URL(request.url);
|
|
37
|
+
if (url.pathname === "/health") return;
|
|
38
|
+
|
|
39
|
+
const password = process.env.OPENCODE_SERVER_PASSWORD;
|
|
40
|
+
|
|
41
|
+
// Password not configured → 503
|
|
42
|
+
if (!password) {
|
|
43
|
+
set.status = 503;
|
|
44
|
+
set.headers["WWW-Authenticate"] = 'Basic realm="ndomo"';
|
|
45
|
+
return { error: "auth_not_configured", message: "set OPENCODE_SERVER_PASSWORD" };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const authHeader = request.headers.get("Authorization");
|
|
49
|
+
|
|
50
|
+
// Missing or malformed header → 401
|
|
51
|
+
if (!authHeader?.startsWith("Basic ")) {
|
|
52
|
+
set.status = 401;
|
|
53
|
+
set.headers["WWW-Authenticate"] = 'Basic realm="ndomo", charset="UTF-8"';
|
|
54
|
+
return { error: "invalid_credentials" };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Decode base64 — format is "user:pass"
|
|
58
|
+
const decoded = Buffer.from(authHeader.slice(6), "base64").toString("utf-8");
|
|
59
|
+
const colonIdx = decoded.indexOf(":");
|
|
60
|
+
const providedPassword = colonIdx >= 0 ? decoded.slice(colonIdx + 1) : decoded;
|
|
61
|
+
|
|
62
|
+
// Timing-safe comparison
|
|
63
|
+
if (!constantTimeEqual(providedPassword, password)) {
|
|
64
|
+
set.status = 401;
|
|
65
|
+
set.headers["WWW-Authenticate"] = 'Basic realm="ndomo", charset="UTF-8"';
|
|
66
|
+
return { error: "invalid_credentials" };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Auth success — continue to handler (return undefined = no short-circuit)
|
|
70
|
+
return;
|
|
71
|
+
});
|
|
72
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// ─── CORS Middleware ──────────────────────────────────────────────────────────
|
|
2
|
+
/**
|
|
3
|
+
* Custom CORS middleware — no external deps.
|
|
4
|
+
*
|
|
5
|
+
* Uses onRequest (not onBeforeHandle) so the hook propagates across
|
|
6
|
+
* Elysia .use() boundaries.
|
|
7
|
+
*
|
|
8
|
+
* Behavior:
|
|
9
|
+
* - origins includes "*" → ACAO: *, credentials NOT allowed (security)
|
|
10
|
+
* - Otherwise → echo request Origin if in allowed list, else no header
|
|
11
|
+
* - Methods: GET, POST, PUT, PATCH, DELETE, OPTIONS
|
|
12
|
+
* - Headers: Content-Type, Authorization, X-Opencode-Directory
|
|
13
|
+
* - Max-Age: 86400
|
|
14
|
+
* - OPTIONS preflight → 204
|
|
15
|
+
*/
|
|
16
|
+
import { Elysia } from "elysia";
|
|
17
|
+
|
|
18
|
+
const ALLOWED_METHODS = "GET, POST, PUT, PATCH, DELETE, OPTIONS";
|
|
19
|
+
const ALLOWED_HEADERS = "Content-Type, Authorization, X-Opencode-Directory";
|
|
20
|
+
const MAX_AGE = "86400";
|
|
21
|
+
|
|
22
|
+
export function corsMiddleware(origins: string[]) {
|
|
23
|
+
const allowAll = origins.includes("*");
|
|
24
|
+
const originSet = new Set(origins);
|
|
25
|
+
|
|
26
|
+
return new Elysia({ name: "cors" }).onRequest(({ request, set }) => {
|
|
27
|
+
const origin = request.headers.get("Origin");
|
|
28
|
+
|
|
29
|
+
// Determine allowed origin
|
|
30
|
+
if (allowAll) {
|
|
31
|
+
set.headers["Access-Control-Allow-Origin"] = "*";
|
|
32
|
+
} else if (origin && originSet.has(origin)) {
|
|
33
|
+
set.headers["Access-Control-Allow-Origin"] = origin;
|
|
34
|
+
set.headers["Vary"] = "Origin";
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Common preflight headers
|
|
38
|
+
set.headers["Access-Control-Allow-Methods"] = ALLOWED_METHODS;
|
|
39
|
+
set.headers["Access-Control-Allow-Headers"] = ALLOWED_HEADERS;
|
|
40
|
+
set.headers["Access-Control-Max-Age"] = MAX_AGE;
|
|
41
|
+
|
|
42
|
+
// Only set credentials when NOT wildcard (browser rejects wildcard+credentials)
|
|
43
|
+
if (!allowAll) {
|
|
44
|
+
set.headers["Access-Control-Allow-Credentials"] = "true";
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Handle OPTIONS preflight
|
|
48
|
+
if (request.method === "OPTIONS") {
|
|
49
|
+
set.status = 204;
|
|
50
|
+
return "";
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// ─── Security Headers Middleware ──────────────────────────────────────────────
|
|
2
|
+
/**
|
|
3
|
+
* Applies OWASP security baseline headers to all HTTP responses.
|
|
4
|
+
* Uses onRequest so headers propagate across Elysia .use() boundaries.
|
|
5
|
+
* Adds X-Powered-By for server identification and HSTS in production.
|
|
6
|
+
*/
|
|
7
|
+
import { Elysia } from "elysia";
|
|
8
|
+
import { SECURITY_HEADERS } from "../../config/schema.ts";
|
|
9
|
+
|
|
10
|
+
export const securityHeaders = new Elysia({ name: "security-headers" }).onRequest(({ set }) => {
|
|
11
|
+
const headers: Record<string, string> = {
|
|
12
|
+
...SECURITY_HEADERS,
|
|
13
|
+
"X-Powered-By": "ndomo",
|
|
14
|
+
};
|
|
15
|
+
if (process.env.NODE_ENV === "production") {
|
|
16
|
+
headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains";
|
|
17
|
+
}
|
|
18
|
+
for (const [k, v] of Object.entries(headers)) {
|
|
19
|
+
set.headers[k] = v;
|
|
20
|
+
}
|
|
21
|
+
});
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// ─── SSE Events Route ────────────────────────────────────────────────────────
|
|
2
|
+
/**
|
|
3
|
+
* GET /api/events — Server-Sent Events stream bridging OpenCode SDK events.
|
|
4
|
+
*
|
|
5
|
+
* Auth: inherited from apiProtected sub-app (httpBasicAuth).
|
|
6
|
+
*
|
|
7
|
+
* Query params:
|
|
8
|
+
* ?types=session.idle,session.error — comma-separated event type filter (default: all)
|
|
9
|
+
*
|
|
10
|
+
* Behavior:
|
|
11
|
+
* - 503 if SDK client is null (server reachable but SDK unreachable)
|
|
12
|
+
* - Sends `hello` event on connection establish (client can detect connect)
|
|
13
|
+
* - Streams events from `sdkClient.event.subscribe()` as SSE
|
|
14
|
+
* - Keepalive comment every 30s to prevent proxy timeouts
|
|
15
|
+
* - Cleanup on client disconnect (abort signal)
|
|
16
|
+
* - On SDK error, sends `error` event then closes stream
|
|
17
|
+
*
|
|
18
|
+
* SSE spec: https://html.spec.whatwg.org/multipage/server-sent-events.html
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import type { OpencodeClient } from "@opencode-ai/sdk/client";
|
|
22
|
+
import { Elysia, t } from "elysia";
|
|
23
|
+
import { createSseWriter } from "../sse.ts";
|
|
24
|
+
|
|
25
|
+
const KEEPALIVE_INTERVAL_MS = 30_000;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Create the SSE events route.
|
|
29
|
+
*
|
|
30
|
+
* @param sdkClient - OpenCode SDK client (may be null if SDK unreachable)
|
|
31
|
+
*/
|
|
32
|
+
export function eventsRoute(sdkClient: OpencodeClient | null) {
|
|
33
|
+
return new Elysia({ name: "events" }).get(
|
|
34
|
+
"/api/events",
|
|
35
|
+
async ({ request, query, set }) => {
|
|
36
|
+
// 503 if SDK client is null (server reachable but no client)
|
|
37
|
+
if (!sdkClient) {
|
|
38
|
+
set.status = 503;
|
|
39
|
+
return {
|
|
40
|
+
error: "sdk_unavailable",
|
|
41
|
+
message: "OpenCode SDK client not initialized",
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Parse optional event type filter
|
|
46
|
+
const filterTypes = query.types
|
|
47
|
+
? new Set(
|
|
48
|
+
query.types
|
|
49
|
+
.split(",")
|
|
50
|
+
.map((s) => s.trim())
|
|
51
|
+
.filter(Boolean),
|
|
52
|
+
)
|
|
53
|
+
: null; // null = all events
|
|
54
|
+
|
|
55
|
+
const stream = new ReadableStream<Uint8Array>({
|
|
56
|
+
async start(controller) {
|
|
57
|
+
const signal = request.signal;
|
|
58
|
+
const writer = createSseWriter(controller, signal);
|
|
59
|
+
|
|
60
|
+
// Keepalive timer — prevents proxy/load-balancer timeouts
|
|
61
|
+
const keepaliveTimer = setInterval(() => writer.writeKeepalive(), KEEPALIVE_INTERVAL_MS);
|
|
62
|
+
writer.onCleanup(() => clearInterval(keepaliveTimer));
|
|
63
|
+
|
|
64
|
+
try {
|
|
65
|
+
// Hello event — client can detect connection established
|
|
66
|
+
writer.write("hello", { timestamp: Date.now() });
|
|
67
|
+
|
|
68
|
+
// Subscribe to SDK events (async generator)
|
|
69
|
+
const result = await sdkClient.event.subscribe();
|
|
70
|
+
|
|
71
|
+
for await (const event of result.stream) {
|
|
72
|
+
if (signal.aborted) break;
|
|
73
|
+
// Apply type filter if specified
|
|
74
|
+
if (filterTypes && event.type && !filterTypes.has(event.type)) continue;
|
|
75
|
+
const eventName = event.type ?? "message";
|
|
76
|
+
writer.write(eventName, event);
|
|
77
|
+
}
|
|
78
|
+
} catch (err) {
|
|
79
|
+
// On SDK error, send a final error event then end
|
|
80
|
+
writer.write("error", {
|
|
81
|
+
message: err instanceof Error ? err.message : String(err),
|
|
82
|
+
code: "sdk_subscribe_failed",
|
|
83
|
+
});
|
|
84
|
+
} finally {
|
|
85
|
+
clearInterval(keepaliveTimer);
|
|
86
|
+
writer.end();
|
|
87
|
+
}
|
|
88
|
+
},
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
// SSE response headers
|
|
92
|
+
set.headers["Content-Type"] = "text/event-stream";
|
|
93
|
+
set.headers["Cache-Control"] = "no-cache, no-transform";
|
|
94
|
+
set.headers["Connection"] = "keep-alive";
|
|
95
|
+
set.headers["X-Accel-Buffering"] = "no"; // disable nginx buffering
|
|
96
|
+
|
|
97
|
+
return new Response(stream, {
|
|
98
|
+
headers: {
|
|
99
|
+
"Content-Type": "text/event-stream",
|
|
100
|
+
"Cache-Control": "no-cache, no-transform",
|
|
101
|
+
Connection: "keep-alive",
|
|
102
|
+
"X-Accel-Buffering": "no",
|
|
103
|
+
},
|
|
104
|
+
});
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
query: t.Object({
|
|
108
|
+
types: t.Optional(t.String()), // comma-separated event type filter
|
|
109
|
+
}),
|
|
110
|
+
},
|
|
111
|
+
);
|
|
112
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// ─── Health Route ─────────────────────────────────────────────────────────────
|
|
2
|
+
/**
|
|
3
|
+
* GET /health — public liveness probe (no auth required).
|
|
4
|
+
*
|
|
5
|
+
* Returns server status, version, uptime, and DB health check.
|
|
6
|
+
* Status is "ok" when DB responds, "degraded" when DB query fails.
|
|
7
|
+
*/
|
|
8
|
+
import type { Database } from "bun:sqlite";
|
|
9
|
+
import { Elysia, t } from "elysia";
|
|
10
|
+
|
|
11
|
+
// Read version once at import time — static for process lifetime
|
|
12
|
+
let cachedVersion = "0.1.0";
|
|
13
|
+
try {
|
|
14
|
+
const pkg = await import("../../../package.json", { with: { type: "json" } });
|
|
15
|
+
cachedVersion = pkg.default?.version ?? "0.1.0";
|
|
16
|
+
} catch {
|
|
17
|
+
// Fallback if package.json not accessible
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function healthRoute(db: Database) {
|
|
21
|
+
return new Elysia({ name: "health" }).get(
|
|
22
|
+
"/health",
|
|
23
|
+
async () => {
|
|
24
|
+
let dbHealthy = true;
|
|
25
|
+
try {
|
|
26
|
+
db.query("SELECT 1").get();
|
|
27
|
+
} catch {
|
|
28
|
+
dbHealthy = false;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return {
|
|
32
|
+
status: dbHealthy ? ("ok" as const) : ("degraded" as const),
|
|
33
|
+
version: cachedVersion,
|
|
34
|
+
uptime: Math.floor(process.uptime() * 1000),
|
|
35
|
+
timestamp: Date.now(),
|
|
36
|
+
dbHealthy,
|
|
37
|
+
};
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
response: {
|
|
41
|
+
200: t.Object({
|
|
42
|
+
status: t.Union([t.Literal("ok"), t.Literal("degraded")]),
|
|
43
|
+
version: t.String(),
|
|
44
|
+
uptime: t.Number(),
|
|
45
|
+
timestamp: t.Number(),
|
|
46
|
+
dbHealthy: t.Boolean(),
|
|
47
|
+
}),
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
);
|
|
51
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// ─── Plans Routes ─────────────────────────────────────────────────────────────
|
|
2
|
+
/**
|
|
3
|
+
* GET /api/plans — list plans (optional filters: status, sessionId, limit)
|
|
4
|
+
* GET /api/plans/search — FTS5 search (required: q, optional: limit)
|
|
5
|
+
* GET /api/plans/:id — get single plan by id
|
|
6
|
+
*/
|
|
7
|
+
import type { Database } from "bun:sqlite";
|
|
8
|
+
import { Elysia, t } from "elysia";
|
|
9
|
+
import { getPlan, listPlans, searchPlans } from "../../db/plans.ts";
|
|
10
|
+
import type { PlanStatus } from "../../db/types.ts";
|
|
11
|
+
|
|
12
|
+
const PlanStatusValues = [
|
|
13
|
+
"draft",
|
|
14
|
+
"approved",
|
|
15
|
+
"executing",
|
|
16
|
+
"completed",
|
|
17
|
+
"failed",
|
|
18
|
+
"abandoned",
|
|
19
|
+
] as const;
|
|
20
|
+
|
|
21
|
+
export function plansRoute(db: Database) {
|
|
22
|
+
return new Elysia({ name: "plans" })
|
|
23
|
+
.get(
|
|
24
|
+
"/api/plans",
|
|
25
|
+
async ({ query }) => {
|
|
26
|
+
const opts: { status?: PlanStatus; sessionId?: string; limit?: number } = {};
|
|
27
|
+
if (query.status) opts.status = query.status as PlanStatus;
|
|
28
|
+
if (query.sessionId) opts.sessionId = query.sessionId;
|
|
29
|
+
if (query.limit) opts.limit = query.limit;
|
|
30
|
+
return listPlans(db, opts);
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
query: t.Object({
|
|
34
|
+
status: t.Optional(t.UnionEnum(PlanStatusValues)),
|
|
35
|
+
sessionId: t.Optional(t.String()),
|
|
36
|
+
limit: t.Optional(t.Number({ minimum: 1, maximum: 500 })),
|
|
37
|
+
}),
|
|
38
|
+
},
|
|
39
|
+
)
|
|
40
|
+
.get(
|
|
41
|
+
"/api/plans/search",
|
|
42
|
+
async ({ query }) => {
|
|
43
|
+
return searchPlans(db, query.q, query.limit ?? 20);
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
query: t.Object({
|
|
47
|
+
q: t.String({ minLength: 1 }),
|
|
48
|
+
limit: t.Optional(t.Number({ minimum: 1, maximum: 100 })),
|
|
49
|
+
}),
|
|
50
|
+
},
|
|
51
|
+
)
|
|
52
|
+
.get(
|
|
53
|
+
"/api/plans/:id",
|
|
54
|
+
async ({ params: { id }, set }) => {
|
|
55
|
+
const plan = getPlan(db, id);
|
|
56
|
+
if (!plan) {
|
|
57
|
+
set.status = 404;
|
|
58
|
+
return { error: "not_found", message: `plan ${id} not found` };
|
|
59
|
+
}
|
|
60
|
+
return plan;
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
params: t.Object({ id: t.String() }),
|
|
64
|
+
},
|
|
65
|
+
);
|
|
66
|
+
}
|