@openparachute/vault 0.7.3 → 0.7.4-rc.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/vault",
3
- "version": "0.7.3",
3
+ "version": "0.7.4-rc.1",
4
4
  "description": "Agent-native knowledge graph. Notes, tags, links over MCP.",
5
5
  "module": "src/cli.ts",
6
6
  "type": "module",
@@ -0,0 +1,188 @@
1
+ /**
2
+ * Unit coverage for the semantic-search (embeddings) opt-in toggle surface
3
+ * (0.7.3 fast-follow). Fully sandboxed — a throwaway `PARACHUTE_HOME` so the
4
+ * config read/write path operates on a scratch `config.yaml`, and the running
5
+ * "active" state is injected (never touches the real process-shared provider
6
+ * memo or downloads a model).
7
+ *
8
+ * The snapshot builder is pure over (env, persisted, active) so most of the
9
+ * matrix (env override wins, restart-required gap, default-off) is exercised
10
+ * without any I/O; the handler tests verify the config write path + 400s.
11
+ */
12
+ import { describe, test, expect, beforeEach, afterEach } from "bun:test";
13
+ import { mkdirSync, rmSync } from "fs";
14
+ import { join } from "path";
15
+ import { tmpdir } from "os";
16
+ import { readGlobalConfig, writeGlobalConfig } from "./config.ts";
17
+ import {
18
+ buildEmbeddingsSnapshot,
19
+ handleEmbeddingsGet,
20
+ handleEmbeddingsPut,
21
+ EMBEDDING_MODEL_DOWNLOAD_MB,
22
+ type EmbeddingsSettingsSnapshot,
23
+ } from "./embeddings-routes.ts";
24
+
25
+ let tmpHome: string;
26
+ let prevHome: string | undefined;
27
+
28
+ beforeEach(() => {
29
+ tmpHome = join(tmpdir(), `vault-embed-routes-${Date.now()}-${Math.random().toString(36).slice(2)}`);
30
+ mkdirSync(join(tmpHome, "vault"), { recursive: true });
31
+ prevHome = process.env.PARACHUTE_HOME;
32
+ process.env.PARACHUTE_HOME = tmpHome;
33
+ // Seed a config.yaml so readGlobalConfig has a file (port only — embeddings unset).
34
+ writeGlobalConfig({ port: 1940 });
35
+ });
36
+
37
+ afterEach(() => {
38
+ if (prevHome === undefined) delete process.env.PARACHUTE_HOME;
39
+ else process.env.PARACHUTE_HOME = prevHome;
40
+ rmSync(tmpHome, { recursive: true, force: true });
41
+ });
42
+
43
+ // An env with no EMBEDDINGS_ENABLED — the common self-host case.
44
+ const NO_ENV: NodeJS.ProcessEnv = {};
45
+
46
+ describe("buildEmbeddingsSnapshot", () => {
47
+ test("default: persisted unset, no env, not active → all off, no restart", () => {
48
+ const snap = buildEmbeddingsSnapshot({ env: NO_ENV, persistedEnabled: undefined, active: false });
49
+ expect(snap).toEqual({
50
+ enabled: false,
51
+ env_override: null,
52
+ env_forced: false,
53
+ effective: false,
54
+ active: false,
55
+ restart_required: false,
56
+ model_download_mb: EMBEDDING_MODEL_DOWNLOAD_MB,
57
+ });
58
+ });
59
+
60
+ test("persisted on but not yet active → effective on, restart required", () => {
61
+ const snap = buildEmbeddingsSnapshot({ env: NO_ENV, persistedEnabled: true, active: false });
62
+ expect(snap.enabled).toBe(true);
63
+ expect(snap.effective).toBe(true);
64
+ expect(snap.active).toBe(false);
65
+ expect(snap.restart_required).toBe(true);
66
+ });
67
+
68
+ test("persisted on and active → no restart required", () => {
69
+ const snap = buildEmbeddingsSnapshot({ env: NO_ENV, persistedEnabled: true, active: true });
70
+ expect(snap.restart_required).toBe(false);
71
+ });
72
+
73
+ test("env override ON wins over persisted OFF", () => {
74
+ const snap = buildEmbeddingsSnapshot({
75
+ env: { EMBEDDINGS_ENABLED: "true" },
76
+ persistedEnabled: false,
77
+ active: true,
78
+ });
79
+ expect(snap.enabled).toBe(false); // toggle still reflects persisted
80
+ expect(snap.env_override).toBe(true);
81
+ expect(snap.env_forced).toBe(true);
82
+ expect(snap.effective).toBe(true); // env forces on
83
+ expect(snap.restart_required).toBe(false); // active matches env-forced effective
84
+ });
85
+
86
+ test("env override OFF wins over persisted ON (with a pending restart while still active)", () => {
87
+ const snap = buildEmbeddingsSnapshot({
88
+ env: { EMBEDDINGS_ENABLED: "false" },
89
+ persistedEnabled: true,
90
+ active: true,
91
+ });
92
+ expect(snap.enabled).toBe(true); // persisted intent preserved
93
+ expect(snap.env_override).toBe(false);
94
+ expect(snap.env_forced).toBe(true);
95
+ expect(snap.effective).toBe(false); // env forces off
96
+ expect(snap.active).toBe(true);
97
+ expect(snap.restart_required).toBe(true); // running still on, env wants off
98
+ });
99
+
100
+ test("unrecognized env value defers to persisted (no force)", () => {
101
+ const snap = buildEmbeddingsSnapshot({
102
+ env: { EMBEDDINGS_ENABLED: "maybe" },
103
+ persistedEnabled: true,
104
+ active: false,
105
+ });
106
+ expect(snap.env_override).toBeNull();
107
+ expect(snap.env_forced).toBe(false);
108
+ expect(snap.effective).toBe(true);
109
+ });
110
+ });
111
+
112
+ describe("handleEmbeddingsGet", () => {
113
+ test("reflects the persisted config.yaml value", async () => {
114
+ writeGlobalConfig({ port: 1940, embeddings_enabled: true });
115
+ const res = handleEmbeddingsGet(false);
116
+ expect(res.status).toBe(200);
117
+ const body = (await res.json()) as EmbeddingsSettingsSnapshot;
118
+ expect(body.enabled).toBe(true);
119
+ expect(body.effective).toBe(true);
120
+ expect(body.active).toBe(false);
121
+ expect(body.restart_required).toBe(true);
122
+ });
123
+
124
+ test("unset persisted → enabled false", async () => {
125
+ const res = handleEmbeddingsGet(false);
126
+ const body = (await res.json()) as EmbeddingsSettingsSnapshot;
127
+ expect(body.enabled).toBe(false);
128
+ });
129
+ });
130
+
131
+ describe("handleEmbeddingsPut", () => {
132
+ function putReq(body: unknown): Request {
133
+ return new Request("http://x/vault/default/.parachute/embeddings", {
134
+ method: "PUT",
135
+ headers: { "content-type": "application/json" },
136
+ body: typeof body === "string" ? body : JSON.stringify(body),
137
+ });
138
+ }
139
+
140
+ test("enabling persists embeddings_enabled: true via the config write path", async () => {
141
+ const res = await handleEmbeddingsPut(putReq({ enabled: true }), false);
142
+ expect(res.status).toBe(200);
143
+ const body = (await res.json()) as EmbeddingsSettingsSnapshot;
144
+ expect(body.enabled).toBe(true);
145
+ expect(body.restart_required).toBe(true); // persisted on, injected active=false
146
+ // The setting is actually on disk (config write path reused, not hand-rolled).
147
+ expect(readGlobalConfig().embeddings_enabled).toBe(true);
148
+ });
149
+
150
+ test("disabling persists embeddings_enabled: false", async () => {
151
+ writeGlobalConfig({ port: 1940, embeddings_enabled: true });
152
+ const res = await handleEmbeddingsPut(putReq({ enabled: false }), true);
153
+ expect(res.status).toBe(200);
154
+ expect(readGlobalConfig().embeddings_enabled).toBe(false);
155
+ });
156
+
157
+ test("preserves other config fields on write (round-trips through serialize)", async () => {
158
+ writeGlobalConfig({ port: 1940, discovery: "disabled", autostart: true });
159
+ await handleEmbeddingsPut(putReq({ enabled: true }), false);
160
+ const cfg = readGlobalConfig();
161
+ expect(cfg.embeddings_enabled).toBe(true);
162
+ expect(cfg.discovery).toBe("disabled");
163
+ expect(cfg.autostart).toBe(true);
164
+ expect(cfg.port).toBe(1940);
165
+ });
166
+
167
+ test("non-boolean enabled → 400", async () => {
168
+ const res = await handleEmbeddingsPut(putReq({ enabled: "yes" }), false);
169
+ expect(res.status).toBe(400);
170
+ const body = (await res.json()) as { field?: string };
171
+ expect(body.field).toBe("enabled");
172
+ });
173
+
174
+ test("missing enabled → 400", async () => {
175
+ const res = await handleEmbeddingsPut(putReq({}), false);
176
+ expect(res.status).toBe(400);
177
+ });
178
+
179
+ test("non-object body → 400", async () => {
180
+ const res = await handleEmbeddingsPut(putReq("[]"), false);
181
+ expect(res.status).toBe(400);
182
+ });
183
+
184
+ test("invalid JSON → 400", async () => {
185
+ const res = await handleEmbeddingsPut(putReq("{not json"), false);
186
+ expect(res.status).toBe(400);
187
+ });
188
+ });
@@ -0,0 +1,178 @@
1
+ /**
2
+ * HTTP surface for the semantic-search (embeddings) opt-in toggle.
3
+ *
4
+ * GET /vault/<name>/.parachute/embeddings — read the persisted setting +
5
+ * the running/effective/env state
6
+ * PUT /vault/<name>/.parachute/embeddings — flip the persisted setting
7
+ *
8
+ * The 0.7.3 fast-follow: 0.7.3 shipped the persisted `embeddings_enabled`
9
+ * config.yaml setting + `resolveEmbeddingsEnabled` (env override → persisted
10
+ * → off), but the ONLY way to flip it was hand-editing config.yaml or setting
11
+ * an env var. This module gives the vault admin SPA a real toggle over that
12
+ * setting.
13
+ *
14
+ * URL note: same reasoning as `mirror-routes.ts` — the admin SPA's static
15
+ * bundle owns `/vault/<name>/admin/*` (vault#252), so the API surface lives
16
+ * under the `.parachute/` module-protocol namespace (sibling to
17
+ * `.parachute/config`, `.parachute/mirror`, `.parachute/usage`).
18
+ *
19
+ * Auth: `vault:admin`, gated upstream in `routing.ts` (this module is the
20
+ * after-auth handler). `embeddings_enabled` is host-global config that affects
21
+ * every vault on the server, but it's reached through the same per-vault admin
22
+ * surface every other settings page uses; the UI copy names the host-wide
23
+ * scope.
24
+ *
25
+ * **Activation is restart-to-apply, not hot.** The embedding provider is
26
+ * resolved ONCE at boot (`getSharedEmbeddingProvider`) and captured into every
27
+ * open store (`Store.embeddingProvider` + `embeddingDisabledReason`, baked at
28
+ * construction) and into the embedding worker (`EmbeddingWorker.provider`, a
29
+ * private readonly field). Re-resolving the provider mid-run would mean
30
+ * mutating readonly fields across the worker AND every already-open store AND
31
+ * resetting a deliberately-once module memo — not clean. So this endpoint
32
+ * PERSISTS the setting and reports `restart_required` when the persisted
33
+ * change hasn't taken effect in the running process yet. Hot-reconfigure is a
34
+ * possible follow-up.
35
+ *
36
+ * **The `EMBEDDINGS_ENABLED` env var still wins.** It's the low-level override
37
+ * (`true`/`1` on, `false`/`0` off, else defer). When it's forcing a value the
38
+ * snapshot carries `env_override` so the UI can flag that the persisted toggle
39
+ * is advisory until the env var is removed — the toggle never lies about
40
+ * what's actually in force.
41
+ */
42
+
43
+ import { readGlobalConfig, writeGlobalConfig } from "./config.ts";
44
+ import { embeddingsEnabledEnvOverride, resolveEmbeddingsEnabled } from "./embedding/select.ts";
45
+ import { isSharedEmbeddingProviderActive } from "./vault-store.ts";
46
+
47
+ /**
48
+ * Approximate size of the bundled ONNX model (`bge-small-en-v1.5`, q8) that
49
+ * lazy-downloads on the FIRST embed after enabling. Surfaced so the UI can
50
+ * warn before the operator flips the switch. (The npm dependency ships ~270MB
51
+ * of runtime, but the model weights fetched on first use are ~34MB.)
52
+ */
53
+ export const EMBEDDING_MODEL_DOWNLOAD_MB = 34;
54
+
55
+ const CORS = { "Access-Control-Allow-Origin": "*" } as const;
56
+
57
+ /**
58
+ * The wire shape the admin SPA reads/writes. Both GET and PUT return this so
59
+ * the SPA reuses one decoder.
60
+ */
61
+ export interface EmbeddingsSettingsSnapshot {
62
+ /**
63
+ * The persisted `embeddings_enabled` in config.yaml — the value the toggle
64
+ * reflects. `false` when unset (semantic search is opt-in, default off).
65
+ */
66
+ enabled: boolean;
67
+ /**
68
+ * The `EMBEDDINGS_ENABLED` env override, tri-state: `true` (forced on),
69
+ * `false` (forced off), or `null` (no env var — defers to `enabled`). When
70
+ * non-null the env var wins over the persisted setting.
71
+ */
72
+ env_override: boolean | null;
73
+ /**
74
+ * Convenience flag: `true` exactly when `env_override` is non-null. The UI
75
+ * shows an advisory banner ("an env var is forcing this") when set.
76
+ */
77
+ env_forced: boolean;
78
+ /**
79
+ * What a fresh boot would resolve: env override, else persisted, else off.
80
+ * This is the state the running process will land in on next restart.
81
+ */
82
+ effective: boolean;
83
+ /**
84
+ * Whether semantic search is LIVE in the running process right now (the
85
+ * boot-resolved provider is present). Distinct from `effective`: a flip of
86
+ * the persisted setting changes `effective` immediately but not `active`.
87
+ */
88
+ active: boolean;
89
+ /**
90
+ * `true` when `active !== effective` — the operator's intended state is
91
+ * persisted but the running process hasn't picked it up. Restart to apply.
92
+ */
93
+ restart_required: boolean;
94
+ /** First-enable model-download size hint (MB) for the UI copy. */
95
+ model_download_mb: number;
96
+ }
97
+
98
+ /**
99
+ * Build the snapshot from the persisted setting + env + running state. Pure
100
+ * over its inputs (env + persisted + active) so it's unit-testable without a
101
+ * live server; the `active` reading is injected (defaults to the real
102
+ * process-shared provider state).
103
+ */
104
+ export function buildEmbeddingsSnapshot(opts?: {
105
+ env?: NodeJS.ProcessEnv;
106
+ persistedEnabled?: boolean;
107
+ active?: boolean;
108
+ }): EmbeddingsSettingsSnapshot {
109
+ const env = opts?.env ?? process.env;
110
+ const persisted = opts?.persistedEnabled ?? false;
111
+ const active = opts?.active ?? isSharedEmbeddingProviderActive();
112
+ const override = embeddingsEnabledEnvOverride(env);
113
+ const effective = resolveEmbeddingsEnabled(env, persisted);
114
+ return {
115
+ enabled: persisted,
116
+ env_override: override ?? null,
117
+ env_forced: override !== undefined,
118
+ effective,
119
+ active,
120
+ restart_required: active !== effective,
121
+ model_download_mb: EMBEDDING_MODEL_DOWNLOAD_MB,
122
+ };
123
+ }
124
+
125
+ /**
126
+ * `GET /vault/<name>/.parachute/embeddings` — return the current settings
127
+ * snapshot. Always 200 (auth enforced upstream). `activeOverride` is a test
128
+ * seam; production passes nothing and the running provider state is read.
129
+ */
130
+ export function handleEmbeddingsGet(activeOverride?: boolean): Response {
131
+ const persisted = readGlobalConfig().embeddings_enabled;
132
+ const snapshot = buildEmbeddingsSnapshot({ persistedEnabled: persisted, active: activeOverride });
133
+ return Response.json(snapshot, { headers: CORS });
134
+ }
135
+
136
+ /**
137
+ * `PUT /vault/<name>/.parachute/embeddings` — persist a new
138
+ * `embeddings_enabled` value. Body: `{ "enabled": boolean }`.
139
+ *
140
+ * Reuses the config write path (`writeGlobalConfig` — never hand-rolls YAML),
141
+ * so the serialize logic that already knows how to persist `embeddings_enabled`
142
+ * is the single source of truth. Persists even when the env var is forcing a
143
+ * value (the operator's persisted intent is recorded; the env override still
144
+ * wins for `effective`/`active`, and the snapshot says so). Returns the same
145
+ * shape as GET, reflecting the new persisted value + the unchanged running
146
+ * state (so `restart_required` is honest right after the write).
147
+ */
148
+ export async function handleEmbeddingsPut(req: Request, activeOverride?: boolean): Promise<Response> {
149
+ let body: unknown;
150
+ try {
151
+ body = await req.json();
152
+ } catch (err) {
153
+ return Response.json(
154
+ { error: "Invalid JSON body", message: (err as Error).message ?? String(err) },
155
+ { status: 400 },
156
+ );
157
+ }
158
+ if (typeof body !== "object" || body === null || Array.isArray(body)) {
159
+ return Response.json(
160
+ { error: "Invalid body", field: "enabled", message: "Expected a JSON object { enabled: boolean }." },
161
+ { status: 400 },
162
+ );
163
+ }
164
+ const enabled = (body as { enabled?: unknown }).enabled;
165
+ if (typeof enabled !== "boolean") {
166
+ return Response.json(
167
+ { error: "Invalid body", field: "enabled", message: "`enabled` must be a boolean." },
168
+ { status: 400 },
169
+ );
170
+ }
171
+
172
+ const config = readGlobalConfig();
173
+ config.embeddings_enabled = enabled;
174
+ writeGlobalConfig(config);
175
+
176
+ const snapshot = buildEmbeddingsSnapshot({ persistedEnabled: enabled, active: activeOverride });
177
+ return Response.json(snapshot, { headers: CORS });
178
+ }
@@ -2716,6 +2716,135 @@ describe("/vault/<name>/.parachute/mirror — auth + dispatch", () => {
2716
2716
  });
2717
2717
  });
2718
2718
 
2719
+ // ---------------------------------------------------------------------------
2720
+ // /vault/<name>/.parachute/embeddings — semantic-search (embeddings) opt-in
2721
+ // toggle admin surface (0.7.3 fast-follow, vault#624).
2722
+ //
2723
+ // Pins the auth gate + GET/PUT dispatch at the routing layer: the endpoint is
2724
+ // `vault:admin`-only (a host-global setting reached through the same per-vault
2725
+ // admin surface as the sibling `.parachute/mirror` / `.parachute/config`
2726
+ // pages). Snapshot shape + the env-override / restart-required semantics are
2727
+ // covered in embeddings-routes.test.ts; here we pin the *security* property —
2728
+ // unauth → 401, read/write → 403, admin → 200 + the flip persists — so it
2729
+ // can't silently regress below the rest of the admin surface. Mirrors the
2730
+ // `.parachute/mirror` auth+dispatch describe above.
2731
+ // ---------------------------------------------------------------------------
2732
+
2733
+ describe("/vault/<name>/.parachute/embeddings — auth + dispatch", () => {
2734
+ const P = "/vault/journal/.parachute/embeddings";
2735
+
2736
+ test("unauthenticated GET → 401", async () => {
2737
+ createVault("journal");
2738
+ const res = await route(new Request(`http://localhost:1940${P}`), P);
2739
+ expect(res.status).toBe(401);
2740
+ });
2741
+
2742
+ test("unauthenticated PUT → 401", async () => {
2743
+ createVault("journal");
2744
+ const res = await route(
2745
+ new Request(`http://localhost:1940${P}`, {
2746
+ method: "PUT",
2747
+ headers: { "content-type": "application/json" },
2748
+ body: JSON.stringify({ enabled: true }),
2749
+ }),
2750
+ P,
2751
+ );
2752
+ expect(res.status).toBe(401);
2753
+ });
2754
+
2755
+ test("vault:read token PUT → 403 insufficient_scope", async () => {
2756
+ createVault("journal");
2757
+ const token = await mintJwt({ vaultName: "journal", scopes: ["vault:journal:read"] });
2758
+ const res = await route(
2759
+ new Request(`http://localhost:1940${P}`, {
2760
+ method: "PUT",
2761
+ headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
2762
+ body: JSON.stringify({ enabled: true }),
2763
+ }),
2764
+ P,
2765
+ );
2766
+ expect(res.status).toBe(403);
2767
+ const body = (await res.json()) as { error_type?: string; required_scope?: string };
2768
+ expect(body.error_type).toBe("insufficient_scope");
2769
+ expect(body.required_scope).toBe("vault:admin");
2770
+ });
2771
+
2772
+ test("vault:write token PUT → 403 insufficient_scope (write ranks below admin)", async () => {
2773
+ createVault("journal");
2774
+ const token = await mintJwt({ vaultName: "journal", scopes: ["vault:journal:write"] });
2775
+ const res = await route(
2776
+ new Request(`http://localhost:1940${P}`, {
2777
+ method: "PUT",
2778
+ headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
2779
+ body: JSON.stringify({ enabled: true }),
2780
+ }),
2781
+ P,
2782
+ );
2783
+ expect(res.status).toBe(403);
2784
+ const body = (await res.json()) as { error_type?: string; required_scope?: string };
2785
+ expect(body.error_type).toBe("insufficient_scope");
2786
+ expect(body.required_scope).toBe("vault:admin");
2787
+ });
2788
+
2789
+ test("cross-vault admin token → 403 (scope names a different vault)", async () => {
2790
+ createVault("journal");
2791
+ createVault("other");
2792
+ // Audience is `journal` (auth passes) but the admin scope names `other`,
2793
+ // so hasScopeForVault denies it against `journal`.
2794
+ const token = await mintJwt({ vaultName: "journal", scopes: ["vault:other:admin"] });
2795
+ const res = await route(
2796
+ new Request(`http://localhost:1940${P}`, {
2797
+ headers: { authorization: `Bearer ${token}` },
2798
+ }),
2799
+ P,
2800
+ );
2801
+ expect(res.status).toBe(403);
2802
+ const body = (await res.json()) as { error_type?: string };
2803
+ expect(body.error_type).toBe("insufficient_scope");
2804
+ });
2805
+
2806
+ test("vault:admin token GET → 200 (default off); admin PUT persists the flip", async () => {
2807
+ createVault("journal");
2808
+ const token = await createAdminToken("journal");
2809
+
2810
+ // GET reaches the handler (auth + admin scope satisfied) → 200 snapshot,
2811
+ // default-off (config seeded with `port` only).
2812
+ const getRes = await route(
2813
+ new Request(`http://localhost:1940${P}`, {
2814
+ headers: { authorization: `Bearer ${token}` },
2815
+ }),
2816
+ P,
2817
+ );
2818
+ expect(getRes.status).toBe(200);
2819
+ const getBody = (await getRes.json()) as { enabled?: boolean };
2820
+ expect(getBody.enabled).toBe(false);
2821
+
2822
+ // PUT flips the persisted setting → 200, echoing the new value.
2823
+ const putRes = await route(
2824
+ new Request(`http://localhost:1940${P}`, {
2825
+ method: "PUT",
2826
+ headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
2827
+ body: JSON.stringify({ enabled: true }),
2828
+ }),
2829
+ P,
2830
+ );
2831
+ expect(putRes.status).toBe(200);
2832
+ const putBody = (await putRes.json()) as { enabled?: boolean };
2833
+ expect(putBody.enabled).toBe(true);
2834
+
2835
+ // A follow-up GET reads the persisted value back → the write stuck.
2836
+ const reGet = await route(
2837
+ new Request(`http://localhost:1940${P}`, {
2838
+ headers: { authorization: `Bearer ${token}` },
2839
+ }),
2840
+ P,
2841
+ );
2842
+ expect(reGet.status).toBe(200);
2843
+ const reBody = (await reGet.json()) as { enabled?: boolean };
2844
+ expect(reBody.enabled).toBe(true);
2845
+ });
2846
+ });
2847
+
2719
2848
  // ---------------------------------------------------------------------------
2720
2849
  // /vault/<name>/.parachute/mirror/run-now — manual-trigger endpoint added
2721
2850
  // alongside the SPA UI. Tests pin the auth gate matches the parent
package/src/routing.ts CHANGED
@@ -116,6 +116,7 @@ import {
116
116
  handleMirrorPut,
117
117
  handleMirrorRunNow,
118
118
  } from "./mirror-routes.ts";
119
+ import { handleEmbeddingsGet, handleEmbeddingsPut } from "./embeddings-routes.ts";
119
120
  import { getMirrorManager } from "./mirror-registry.ts";
120
121
  import { buildUsageReport } from "./usage.ts";
121
122
  import { handleTicketSpend } from "./attachment-tickets.ts";
@@ -687,6 +688,33 @@ export async function route(
687
688
  return Response.json(buildUsageReport(vaultName, stats, { fresh }));
688
689
  }
689
690
 
691
+ // /.parachute/embeddings — Admin-gated read+write of the semantic-search
692
+ // (embeddings) opt-in toggle. The 0.7.3 fast-follow: gives the admin SPA a
693
+ // real toggle over the persisted `embeddings_enabled` config.yaml setting so
694
+ // an operator flips semantic search on from the UI instead of hand-editing
695
+ // config or setting an env var. Host-global setting (affects every vault),
696
+ // reached through the same per-vault admin surface as the other settings
697
+ // pages. Activation is restart-to-apply — the endpoint persists the setting
698
+ // and reports `restart_required`; see embeddings-routes.ts for why the boot-
699
+ // captured provider isn't hot-reconfigured.
700
+ if (subpath === "/.parachute/embeddings") {
701
+ if (!hasScopeForVault(auth.scopes, vaultName, "admin")) {
702
+ return Response.json(
703
+ {
704
+ error: "Forbidden",
705
+ error_type: "insufficient_scope",
706
+ message: `This endpoint requires the '${SCOPE_ADMIN}' scope (or '${SCOPE_ADMIN.replace("vault:", `vault:${vaultName}:`)}').`,
707
+ required_scope: SCOPE_ADMIN,
708
+ granted_scopes: auth.scopes,
709
+ },
710
+ { status: 403 },
711
+ );
712
+ }
713
+ if (req.method === "GET") return handleEmbeddingsGet();
714
+ if (req.method === "PUT") return handleEmbeddingsPut(req);
715
+ return Response.json({ error: "Method not allowed" }, { status: 405 });
716
+ }
717
+
690
718
  // The per-vault `/tokens` REST surface (pvt_* mint/list/revoke) was removed
691
719
  // at 0.5.0 (vault#282 Stage 2 — vault is a pure hub resource-server). Hub
692
720
  // JWTs are minted via hub's registry (`/api/auth/mint-token`); a `/tokens`
@@ -68,6 +68,21 @@ export function getSharedEmbeddingProvider(): EmbeddingProvider | undefined {
68
68
  return sharedEmbeddingProvider;
69
69
  }
70
70
 
71
+ /**
72
+ * Whether semantic search is LIVE in this running process right now — i.e.
73
+ * the boot-resolved shared provider is present. This is the honest
74
+ * "currently active" signal the admin settings surface reports, distinct
75
+ * from the *persisted* `embeddings_enabled` setting: because the provider
76
+ * is memoized at boot (into every open store + the embedding worker),
77
+ * flipping the persisted setting does NOT change this until the next
78
+ * server restart. The admin toggle compares this against the freshly
79
+ * resolved effective state to tell the operator when a restart is pending
80
+ * (see `src/embeddings-routes.ts`).
81
+ */
82
+ export function isSharedEmbeddingProviderActive(): boolean {
83
+ return getSharedEmbeddingProvider() !== undefined;
84
+ }
85
+
71
86
  /** Test-only: force a fresh provider on the next `getSharedEmbeddingProvider()` call. */
72
87
  export function resetSharedEmbeddingProviderForTests(): void {
73
88
  sharedEmbeddingProvider = undefined;