@openparachute/vault 0.7.3-rc.11 → 0.7.3-rc.13
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 +1 -1
- package/src/auth-hub-jwt.test.ts +118 -1
- package/src/auth.ts +64 -0
- package/src/oauth-discovery.ts +31 -0
- package/src/routing.test.ts +229 -4
- package/src/routing.ts +135 -23
- package/src/scopes.ts +22 -0
- package/src/transcription-worker.test.ts +151 -0
- package/src/transcription-worker.ts +113 -52
package/package.json
CHANGED
package/src/auth-hub-jwt.test.ts
CHANGED
|
@@ -28,7 +28,7 @@ import { tmpdir } from "os";
|
|
|
28
28
|
import { generateKeyPair, exportJWK, SignJWT } from "jose";
|
|
29
29
|
import { writeVaultConfig, readVaultConfig } from "./config.ts";
|
|
30
30
|
import { getVaultStore, clearVaultStoreCache } from "./vault-store.ts";
|
|
31
|
-
import { authenticateVaultRequest, authenticateGlobalRequest } from "./auth.ts";
|
|
31
|
+
import { authenticateVaultRequest, authenticateGlobalRequest, deriveVaultFromToken } from "./auth.ts";
|
|
32
32
|
import { resetJwksCache, resetRevocationCache } from "./hub-jwt.ts";
|
|
33
33
|
|
|
34
34
|
interface Keypair {
|
|
@@ -705,3 +705,120 @@ describe("pvt_* DROP (vault#282 Stage 2 — unvalidatable)", () => {
|
|
|
705
705
|
expect("error" in result).toBe(false);
|
|
706
706
|
});
|
|
707
707
|
});
|
|
708
|
+
|
|
709
|
+
// ---------------------------------------------------------------------------
|
|
710
|
+
// deriveVaultFromToken — the derivation precedence for the canonical root
|
|
711
|
+
// `/mcp` endpoint (U1). This function READS a validated token's claims to name
|
|
712
|
+
// the target vault; it never authorizes (the router re-dispatches through the
|
|
713
|
+
// full per-vault machinery). These cases isolate the three naming sources —
|
|
714
|
+
// narrowed scope / `aud=vault.<name>` / single-element `vault_scope` — and the
|
|
715
|
+
// fail-closed rules (agree → one name; disagree or none → `not_derivable`).
|
|
716
|
+
// The end-to-end routing.test.ts covers the wired behavior; this pins the
|
|
717
|
+
// precedence logic directly.
|
|
718
|
+
// ---------------------------------------------------------------------------
|
|
719
|
+
describe("deriveVaultFromToken — root /mcp vault derivation (U1)", () => {
|
|
720
|
+
test("all three sources agree → that vault", async () => {
|
|
721
|
+
const token = await signJwt(kp, {
|
|
722
|
+
iss: fixture.origin,
|
|
723
|
+
aud: "vault.journal",
|
|
724
|
+
scope: "vault:journal:write",
|
|
725
|
+
vaultScope: ["journal"],
|
|
726
|
+
});
|
|
727
|
+
expect(await deriveVaultFromToken(bearer(token))).toEqual({ vaultName: "journal" });
|
|
728
|
+
});
|
|
729
|
+
|
|
730
|
+
test("narrowed scope alone names the vault (non-vault aud, no vault_scope)", async () => {
|
|
731
|
+
const token = await signJwt(kp, {
|
|
732
|
+
iss: fixture.origin,
|
|
733
|
+
aud: "urn:opaque-resource",
|
|
734
|
+
scope: "vault:journal:read",
|
|
735
|
+
});
|
|
736
|
+
expect(await deriveVaultFromToken(bearer(token))).toEqual({ vaultName: "journal" });
|
|
737
|
+
});
|
|
738
|
+
|
|
739
|
+
test("aud=vault.<name> alone names the vault (broad scope names nothing)", async () => {
|
|
740
|
+
const token = await signJwt(kp, {
|
|
741
|
+
iss: fixture.origin,
|
|
742
|
+
aud: "vault.journal",
|
|
743
|
+
scope: "vault:read",
|
|
744
|
+
});
|
|
745
|
+
expect(await deriveVaultFromToken(bearer(token))).toEqual({ vaultName: "journal" });
|
|
746
|
+
});
|
|
747
|
+
|
|
748
|
+
test("single-element vault_scope alone names the vault", async () => {
|
|
749
|
+
const token = await signJwt(kp, {
|
|
750
|
+
iss: fixture.origin,
|
|
751
|
+
aud: "urn:opaque-resource",
|
|
752
|
+
scope: "vault:read",
|
|
753
|
+
vaultScope: ["journal"],
|
|
754
|
+
});
|
|
755
|
+
expect(await deriveVaultFromToken(bearer(token))).toEqual({ vaultName: "journal" });
|
|
756
|
+
});
|
|
757
|
+
|
|
758
|
+
test("multi-element vault_scope is NOT a single name → not_derivable (nothing else names)", async () => {
|
|
759
|
+
// Phase-2 multi-vault shape: a multi-element vault_scope doesn't name ONE
|
|
760
|
+
// vault, so at the single-vault root it can't be the sole source.
|
|
761
|
+
const token = await signJwt(kp, {
|
|
762
|
+
iss: fixture.origin,
|
|
763
|
+
aud: "urn:opaque-resource",
|
|
764
|
+
scope: "vault:read",
|
|
765
|
+
vaultScope: ["journal", "work"],
|
|
766
|
+
});
|
|
767
|
+
expect(await deriveVaultFromToken(bearer(token))).toEqual({ error: "not_derivable" });
|
|
768
|
+
});
|
|
769
|
+
|
|
770
|
+
test("sources disagree (scope vs aud) → not_derivable, never a guess", async () => {
|
|
771
|
+
const token = await signJwt(kp, {
|
|
772
|
+
iss: fixture.origin,
|
|
773
|
+
aud: "vault.work",
|
|
774
|
+
scope: "vault:journal:write",
|
|
775
|
+
});
|
|
776
|
+
expect(await deriveVaultFromToken(bearer(token))).toEqual({ error: "not_derivable" });
|
|
777
|
+
});
|
|
778
|
+
|
|
779
|
+
test("no source names a vault → not_derivable", async () => {
|
|
780
|
+
const token = await signJwt(kp, {
|
|
781
|
+
iss: fixture.origin,
|
|
782
|
+
aud: "urn:opaque-resource",
|
|
783
|
+
scope: "vault:read",
|
|
784
|
+
});
|
|
785
|
+
expect(await deriveVaultFromToken(bearer(token))).toEqual({ error: "not_derivable" });
|
|
786
|
+
});
|
|
787
|
+
|
|
788
|
+
test("no bearer → no_bearer", async () => {
|
|
789
|
+
const req = new Request("https://vault.test/mcp");
|
|
790
|
+
expect(await deriveVaultFromToken(req)).toEqual({ error: "no_bearer" });
|
|
791
|
+
});
|
|
792
|
+
|
|
793
|
+
test("non-JWT bearer (operator / legacy shape) names no vault → not_derivable", async () => {
|
|
794
|
+
// The operator VAULT_AUTH_TOKEN and legacy YAML keys are vault-agnostic —
|
|
795
|
+
// they can't route the token-derived root endpoint (they keep working at
|
|
796
|
+
// the per-vault URL).
|
|
797
|
+
expect(await deriveVaultFromToken(bearer("opaque-operator-secret"))).toEqual({
|
|
798
|
+
error: "not_derivable",
|
|
799
|
+
});
|
|
800
|
+
});
|
|
801
|
+
|
|
802
|
+
test("expired JWT → not_derivable (validated with the full trust kernel)", async () => {
|
|
803
|
+
const token = await signJwt(kp, {
|
|
804
|
+
iss: fixture.origin,
|
|
805
|
+
aud: "vault.journal",
|
|
806
|
+
scope: "vault:journal:write",
|
|
807
|
+
ttlSeconds: -10, // already expired
|
|
808
|
+
});
|
|
809
|
+
expect(await deriveVaultFromToken(bearer(token))).toEqual({ error: "not_derivable" });
|
|
810
|
+
});
|
|
811
|
+
|
|
812
|
+
test("revoked JWT → not_derivable (revocation runs in derivation too)", async () => {
|
|
813
|
+
const jti = "u1-derive-revoked";
|
|
814
|
+
const token = await signJwt(kp, {
|
|
815
|
+
iss: fixture.origin,
|
|
816
|
+
aud: "vault.journal",
|
|
817
|
+
scope: "vault:journal:write",
|
|
818
|
+
jti,
|
|
819
|
+
});
|
|
820
|
+
fixture.setRevoked([jti]);
|
|
821
|
+
resetRevocationCache();
|
|
822
|
+
expect(await deriveVaultFromToken(bearer(token))).toEqual({ error: "not_derivable" });
|
|
823
|
+
});
|
|
824
|
+
});
|
package/src/auth.ts
CHANGED
|
@@ -30,6 +30,7 @@ import {
|
|
|
30
30
|
hasScope,
|
|
31
31
|
hasScopeForVault,
|
|
32
32
|
legacyPermissionToScopes,
|
|
33
|
+
narrowedVaultNames,
|
|
33
34
|
SCOPE_ADMIN,
|
|
34
35
|
SCOPE_READ,
|
|
35
36
|
SCOPE_WRITE,
|
|
@@ -712,3 +713,66 @@ export async function authenticateGlobalRequest(
|
|
|
712
713
|
}
|
|
713
714
|
return { error: Response.json({ error: "Unauthorized", message: "Invalid API key" }, { status: 401 }) };
|
|
714
715
|
}
|
|
716
|
+
|
|
717
|
+
/**
|
|
718
|
+
* Outcome of deriving a target vault from a request's bearer at the canonical
|
|
719
|
+
* root `/mcp` endpoint (U1). `vaultName` on success; a coarse failure `error`
|
|
720
|
+
* otherwise. Both failure reasons map to the SAME 401 + root-PRM challenge at
|
|
721
|
+
* the router — the distinction exists for logging/tests, never leaks to the
|
|
722
|
+
* client. `no_bearer` = no credential presented; `not_derivable` = a credential
|
|
723
|
+
* that names no single vault (non-JWT operator/legacy bearer, invalid /
|
|
724
|
+
* expired / revoked JWT, or a JWT whose scope / `aud` / `vault_scope` sources
|
|
725
|
+
* name zero or conflicting vaults).
|
|
726
|
+
*/
|
|
727
|
+
export type VaultDerivation =
|
|
728
|
+
| { vaultName: string }
|
|
729
|
+
| { error: "no_bearer" | "not_derivable" };
|
|
730
|
+
|
|
731
|
+
/**
|
|
732
|
+
* Derive the target vault from a request's bearer token WITHOUT authorizing the
|
|
733
|
+
* request. The caller re-dispatches the derived name through the full per-vault
|
|
734
|
+
* auth machinery (`authenticateVaultRequest`), which re-validates the token
|
|
735
|
+
* WITH the audience pin — derive-then-redispatch, so a bad derivation FAILS the
|
|
736
|
+
* inner check rather than bypassing it (defense in depth). This function only
|
|
737
|
+
* reads the claims well enough to name the vault; it is never the authorization
|
|
738
|
+
* gate.
|
|
739
|
+
*
|
|
740
|
+
* Only hub-issued JWTs name a vault. The operator `VAULT_AUTH_TOKEN` and legacy
|
|
741
|
+
* YAML keys are vault-agnostic (they name no resource — see scopes.ts), so they
|
|
742
|
+
* return `not_derivable` here and keep working at the URL-addressed
|
|
743
|
+
* `/vault/<name>/*` surface. The JWT is validated with the SAME scope-guard
|
|
744
|
+
* trust kernel the per-vault path uses (signature, `iss` pin, `jti` +
|
|
745
|
+
* revocation, expiry) but WITHOUT `expectedAudience` — at the root we don't yet
|
|
746
|
+
* know which audience to expect; that pin is re-applied by the re-dispatch.
|
|
747
|
+
*
|
|
748
|
+
* From the validated claims, three independent sources can name a vault:
|
|
749
|
+
* 1. a narrowed `vault:<name>:<verb>` scope,
|
|
750
|
+
* 2. an `aud` of the form `vault.<name>`,
|
|
751
|
+
* 3. a single-element `vault_scope` claim.
|
|
752
|
+
* On a hub-minted token these AGREE. We collect every name any source provides
|
|
753
|
+
* and require EXACTLY ONE distinct name: zero (nothing named a vault) and
|
|
754
|
+
* two-or-more (the sources disagree) both fail closed with `not_derivable`. We
|
|
755
|
+
* never pick a winner from a precedence order — an ambiguous or unnamed token
|
|
756
|
+
* gets the discovery challenge, not a silent guess.
|
|
757
|
+
*/
|
|
758
|
+
export async function deriveVaultFromToken(req: Request): Promise<VaultDerivation> {
|
|
759
|
+
const key = extractApiKey(req);
|
|
760
|
+
if (!key) return { error: "no_bearer" };
|
|
761
|
+
if (!looksLikeJwt(key)) return { error: "not_derivable" };
|
|
762
|
+
let claims;
|
|
763
|
+
try {
|
|
764
|
+
// No `expectedAudience`: the per-vault trust kernel minus the aud pin
|
|
765
|
+
// (which the re-dispatch re-applies). A bad signature / iss / expiry /
|
|
766
|
+
// revoked jti throws here → not_derivable → the standard 401 challenge.
|
|
767
|
+
claims = await validateHubJwt(key, {});
|
|
768
|
+
} catch {
|
|
769
|
+
return { error: "not_derivable" };
|
|
770
|
+
}
|
|
771
|
+
const named = new Set<string>();
|
|
772
|
+
for (const name of narrowedVaultNames(claims.scopes)) named.add(name);
|
|
773
|
+
const audMatch = claims.aud?.match(/^vault\.(.+)$/);
|
|
774
|
+
if (audMatch) named.add(audMatch[1]!);
|
|
775
|
+
if (claims.vaultScope.length === 1) named.add(claims.vaultScope[0]!);
|
|
776
|
+
if (named.size !== 1) return { error: "not_derivable" };
|
|
777
|
+
return { vaultName: [...named][0]! };
|
|
778
|
+
}
|
package/src/oauth-discovery.ts
CHANGED
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
*/
|
|
28
28
|
|
|
29
29
|
import { getHubOrigin } from "./hub-jwt.ts";
|
|
30
|
+
import { SCOPE_READ, SCOPE_WRITE } from "./scopes.ts";
|
|
30
31
|
|
|
31
32
|
/**
|
|
32
33
|
* OAuth scopes vault publishes through discovery, RESOURCE-NARROWED to the
|
|
@@ -87,6 +88,36 @@ export function handleProtectedResource(req: Request, vaultName: string): Respon
|
|
|
87
88
|
});
|
|
88
89
|
}
|
|
89
90
|
|
|
91
|
+
/**
|
|
92
|
+
* Protected Resource Metadata (RFC 9728) for the CANONICAL ROOT `/mcp`
|
|
93
|
+
* endpoint (U1). Same document shape as `handleProtectedResource`, with two
|
|
94
|
+
* deliberate differences that follow from the root being vault-AGNOSTIC (the
|
|
95
|
+
* vault is derived from the token, not the URL):
|
|
96
|
+
*
|
|
97
|
+
* - `resource` is the origin-root `<base>/mcp`, not a `/vault/<name>/mcp`.
|
|
98
|
+
* - `scopes_supported` advertises the UN-NARROWED forms `vault:read` /
|
|
99
|
+
* `vault:write` (there's no vault name to narrow to here). A spec-following
|
|
100
|
+
* client reads these and requests the broad forms; the hub's consent picker
|
|
101
|
+
* narrows the grant to a chosen vault at authorization time (that path
|
|
102
|
+
* already exists hub-side), minting a token stamped with a
|
|
103
|
+
* `vault:<name>:<verb>` scope + `aud=vault.<name>` — exactly what the root
|
|
104
|
+
* endpoint derives the target vault from. `admin` is intentionally omitted:
|
|
105
|
+
* the interactive connect flow grants read/write; admin is an operator
|
|
106
|
+
* concern, not something the consent picker offers.
|
|
107
|
+
*
|
|
108
|
+
* Served at the RFC 9728 §3.1 path-insertion location for the root resource:
|
|
109
|
+
* `/.well-known/oauth-protected-resource/mcp`.
|
|
110
|
+
*/
|
|
111
|
+
export function handleRootProtectedResource(req: Request): Response {
|
|
112
|
+
const base = getBaseUrl(req);
|
|
113
|
+
return Response.json({
|
|
114
|
+
resource: `${base}/mcp`,
|
|
115
|
+
authorization_servers: [getHubOrigin()],
|
|
116
|
+
scopes_supported: [SCOPE_READ, SCOPE_WRITE],
|
|
117
|
+
bearer_methods_supported: ["header"],
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
90
121
|
/**
|
|
91
122
|
* OAuth 2.0 Authorization Server Metadata (RFC 8414).
|
|
92
123
|
*
|
package/src/routing.test.ts
CHANGED
|
@@ -10,8 +10,10 @@
|
|
|
10
10
|
* 3. `/vault/<name>/...` — per-vault routing (OAuth, MCP, view, API).
|
|
11
11
|
* 4. The RFC 9728 WWW-Authenticate challenge that decorates MCP 401s.
|
|
12
12
|
*
|
|
13
|
-
* No unscoped `/
|
|
14
|
-
*
|
|
13
|
+
* No unscoped `/api/*` or `/oauth/*` routes exist — those per-vault resources
|
|
14
|
+
* must name the vault in the URL. The canonical root `/mcp` (U1) is the one
|
|
15
|
+
* vault-agnostic exception: it derives the target vault from the token, then
|
|
16
|
+
* re-dispatches through the same per-vault machinery.
|
|
15
17
|
*
|
|
16
18
|
* Uses PARACHUTE_HOME override so each test's vaults live in a tmp dir and
|
|
17
19
|
* never touch ~/.parachute.
|
|
@@ -706,14 +708,27 @@ describe("per-vault routing under /vault/<name>/", () => {
|
|
|
706
708
|
}
|
|
707
709
|
});
|
|
708
710
|
|
|
709
|
-
test("no /
|
|
711
|
+
test("no /api, /oauth unscoped routes — all 404", async () => {
|
|
710
712
|
createVault("journal");
|
|
711
|
-
for (const path of ["/
|
|
713
|
+
for (const path of ["/api/notes", "/oauth/register", "/oauth/authorize"]) {
|
|
712
714
|
const res = await route(new Request(`http://localhost:1940${path}`), path);
|
|
713
715
|
expect(res.status).toBe(404);
|
|
714
716
|
}
|
|
715
717
|
});
|
|
716
718
|
|
|
719
|
+
test("root /mcp is NOT a 404 — it's the token-derived MCP endpoint (401 unauthenticated)", async () => {
|
|
720
|
+
// The old unscoped `/mcp` compat prefix returned 404. The canonical root
|
|
721
|
+
// `/mcp` (U1) replaces it: unauthenticated it 401s + carries the root PRM
|
|
722
|
+
// challenge, exactly like the per-vault endpoint but pointed at the root
|
|
723
|
+
// metadata document.
|
|
724
|
+
createVault("journal");
|
|
725
|
+
const res = await route(new Request("http://localhost:1940/mcp", { method: "POST" }), "/mcp");
|
|
726
|
+
expect(res.status).toBe(401);
|
|
727
|
+
expect(res.headers.get("WWW-Authenticate")).toBe(
|
|
728
|
+
'Bearer resource_metadata="http://localhost:1940/.well-known/oauth-protected-resource/mcp"',
|
|
729
|
+
);
|
|
730
|
+
});
|
|
731
|
+
|
|
717
732
|
test("bare /vault/<name> returns metadata for authenticated callers", async () => {
|
|
718
733
|
createVault("journal", "My journal vault");
|
|
719
734
|
const path = "/vault/journal";
|
|
@@ -810,6 +825,216 @@ describe("MCP 401 WWW-Authenticate challenge (RFC 9728)", () => {
|
|
|
810
825
|
});
|
|
811
826
|
});
|
|
812
827
|
|
|
828
|
+
// ---------------------------------------------------------------------------
|
|
829
|
+
// Canonical root MCP endpoint — token-derived vault dispatch (U1).
|
|
830
|
+
//
|
|
831
|
+
// `/mcp` at the origin root derives the target vault from the TOKEN, not the
|
|
832
|
+
// URL, then re-dispatches through the identical per-vault machinery. These
|
|
833
|
+
// tests pin the two load-bearing properties: (1) a token for vault A behaves
|
|
834
|
+
// byte-identically at root `/mcp` and at `/vault/A/mcp` (equivalence), and
|
|
835
|
+
// (2) the vault is ALWAYS the one the token names — a token can't be steered
|
|
836
|
+
// to another vault, and an unnameable/ambiguous token is refused with the root
|
|
837
|
+
// discovery challenge rather than a guess (derivation honesty).
|
|
838
|
+
// ---------------------------------------------------------------------------
|
|
839
|
+
|
|
840
|
+
describe("canonical root /mcp — token-derived vault dispatch (U1)", () => {
|
|
841
|
+
const INIT = {
|
|
842
|
+
jsonrpc: "2.0",
|
|
843
|
+
id: 1,
|
|
844
|
+
method: "initialize",
|
|
845
|
+
params: {
|
|
846
|
+
protocolVersion: "2025-06-18",
|
|
847
|
+
capabilities: {},
|
|
848
|
+
clientInfo: { name: "u1-test", version: "0" },
|
|
849
|
+
},
|
|
850
|
+
};
|
|
851
|
+
const LIST = { jsonrpc: "2.0", id: 2, method: "tools/list" };
|
|
852
|
+
const QUERY = {
|
|
853
|
+
jsonrpc: "2.0",
|
|
854
|
+
id: 3,
|
|
855
|
+
method: "tools/call",
|
|
856
|
+
params: { name: "query-notes", arguments: {} },
|
|
857
|
+
};
|
|
858
|
+
|
|
859
|
+
async function mcpPost(path: string, token: string | null, rpc: object): Promise<Response> {
|
|
860
|
+
const headers: Record<string, string> = {
|
|
861
|
+
"content-type": "application/json",
|
|
862
|
+
accept: "application/json, text/event-stream",
|
|
863
|
+
};
|
|
864
|
+
if (token) headers.authorization = `Bearer ${token}`;
|
|
865
|
+
return route(
|
|
866
|
+
new Request(`http://localhost:1940${path}`, {
|
|
867
|
+
method: "POST",
|
|
868
|
+
headers,
|
|
869
|
+
body: JSON.stringify(rpc),
|
|
870
|
+
}),
|
|
871
|
+
path,
|
|
872
|
+
);
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
/** Parse a JSON-RPC frame off an MCP response, tolerating either the JSON
|
|
876
|
+
* body (enableJsonResponse) or an SSE `data:` framing. */
|
|
877
|
+
async function rpcFrame(res: Response): Promise<unknown> {
|
|
878
|
+
const text = await res.text();
|
|
879
|
+
const trimmed = text.trimStart();
|
|
880
|
+
if (trimmed.startsWith("{") || trimmed.startsWith("[")) return JSON.parse(trimmed);
|
|
881
|
+
const dataLine = text.split("\n").find((l) => l.startsWith("data:"));
|
|
882
|
+
return JSON.parse(dataLine!.slice("data:".length).trim());
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
/** Sign a token directly (bypassing `mintJwt`'s `aud=vault.<name>` coupling)
|
|
886
|
+
* so a test can craft a token whose sources name no vault, or disagree. */
|
|
887
|
+
async function signRaw(payload: Record<string, unknown>, aud: string): Promise<string> {
|
|
888
|
+
const iat = Math.floor(Date.now() / 1000);
|
|
889
|
+
return new SignJWT({ client_id: "u1-test", ...payload })
|
|
890
|
+
.setProtectedHeader({ alg: "RS256", kid: KID })
|
|
891
|
+
.setIssuer(`http://127.0.0.1:${hubServer.port}`)
|
|
892
|
+
.setSubject("u1-test-user")
|
|
893
|
+
.setAudience(aud)
|
|
894
|
+
.setIssuedAt(iat)
|
|
895
|
+
.setExpirationTime(iat + 60)
|
|
896
|
+
.setJti(`jti-${Math.random().toString(36).slice(2)}`)
|
|
897
|
+
.sign(signingKey);
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
test("root PRM mirrors the per-vault PRM shape (root resource, un-narrowed scopes)", async () => {
|
|
901
|
+
createVault("journal");
|
|
902
|
+
const path = "/.well-known/oauth-protected-resource/mcp";
|
|
903
|
+
const res = await route(new Request(`http://localhost:1940${path}`), path);
|
|
904
|
+
expect(res.status).toBe(200);
|
|
905
|
+
const prm = (await res.json()) as {
|
|
906
|
+
resource: string;
|
|
907
|
+
authorization_servers: string[];
|
|
908
|
+
scopes_supported: string[];
|
|
909
|
+
bearer_methods_supported: string[];
|
|
910
|
+
};
|
|
911
|
+
expect(prm.resource).toBe("http://localhost:1940/mcp");
|
|
912
|
+
// Un-narrowed forms — the hub's consent picker narrows them to a vault.
|
|
913
|
+
expect(prm.scopes_supported).toEqual(["vault:read", "vault:write"]);
|
|
914
|
+
expect(prm.bearer_methods_supported).toEqual(["header"]);
|
|
915
|
+
expect(Array.isArray(prm.authorization_servers)).toBe(true);
|
|
916
|
+
expect(prm.authorization_servers.length).toBe(1);
|
|
917
|
+
});
|
|
918
|
+
|
|
919
|
+
test("unauthenticated → 401 whose challenge names the root PRM (self-consistent pointer)", async () => {
|
|
920
|
+
createVault("journal");
|
|
921
|
+
const res = await mcpPost("/mcp", null, INIT);
|
|
922
|
+
expect(res.status).toBe(401);
|
|
923
|
+
const header = res.headers.get("WWW-Authenticate")!;
|
|
924
|
+
const prmUrl = header.match(/resource_metadata="([^"]+)"/)![1]!;
|
|
925
|
+
const prmPath = new URL(prmUrl).pathname;
|
|
926
|
+
expect(prmPath).toBe("/.well-known/oauth-protected-resource/mcp");
|
|
927
|
+
// The pointer resolves to a real PRM that names the root resource.
|
|
928
|
+
const prmRes = await route(new Request(prmUrl), prmPath);
|
|
929
|
+
expect(prmRes.status).toBe(200);
|
|
930
|
+
expect(((await prmRes.json()) as { resource: string }).resource).toBe(
|
|
931
|
+
"http://localhost:1940/mcp",
|
|
932
|
+
);
|
|
933
|
+
});
|
|
934
|
+
|
|
935
|
+
test("x-forwarded-* shape the root challenge URL", async () => {
|
|
936
|
+
createVault("journal");
|
|
937
|
+
const req = new Request("http://127.0.0.1:1940/mcp", {
|
|
938
|
+
method: "POST",
|
|
939
|
+
headers: {
|
|
940
|
+
"x-forwarded-host": "vault.example.com",
|
|
941
|
+
"x-forwarded-proto": "https",
|
|
942
|
+
},
|
|
943
|
+
});
|
|
944
|
+
const res = await route(req, "/mcp");
|
|
945
|
+
expect(res.status).toBe(401);
|
|
946
|
+
expect(res.headers.get("WWW-Authenticate")).toBe(
|
|
947
|
+
'Bearer resource_metadata="https://vault.example.com/.well-known/oauth-protected-resource/mcp"',
|
|
948
|
+
);
|
|
949
|
+
});
|
|
950
|
+
|
|
951
|
+
test("token for A at root /mcp === same token at /vault/A/mcp (initialize, tools/list, tool call)", async () => {
|
|
952
|
+
createVault("journal");
|
|
953
|
+
const store = getVaultStore("journal");
|
|
954
|
+
await store.createNote("root-mcp equivalence fixture", { path: "fixture", tags: ["probe"] });
|
|
955
|
+
const token = await mintJwt({ vaultName: "journal", scopes: ["vault:journal:admin"] });
|
|
956
|
+
|
|
957
|
+
for (const rpc of [INIT, LIST, QUERY]) {
|
|
958
|
+
const rootRes = await mcpPost("/mcp", token, rpc);
|
|
959
|
+
const perVaultRes = await mcpPost("/vault/journal/mcp", token, rpc);
|
|
960
|
+
expect(rootRes.status).toBe(200);
|
|
961
|
+
expect(perVaultRes.status).toBe(200);
|
|
962
|
+
// Byte-identical JSON-RPC frame — the root path derived `journal`, so the
|
|
963
|
+
// server name, tool list, and tool result all match the URL-addressed
|
|
964
|
+
// endpoint exactly.
|
|
965
|
+
expect(await rpcFrame(rootRes)).toEqual(await rpcFrame(perVaultRes));
|
|
966
|
+
}
|
|
967
|
+
});
|
|
968
|
+
|
|
969
|
+
test("a read-only token narrows the root tool list identically to the per-vault endpoint", async () => {
|
|
970
|
+
createVault("journal");
|
|
971
|
+
getVaultStore("journal");
|
|
972
|
+
const readToken = await mintJwt({ vaultName: "journal", scopes: ["vault:journal:read"] });
|
|
973
|
+
const rootList = await rpcFrame(await mcpPost("/mcp", readToken, LIST));
|
|
974
|
+
const perVaultList = await rpcFrame(await mcpPost("/vault/journal/mcp", readToken, LIST));
|
|
975
|
+
expect(rootList).toEqual(perVaultList);
|
|
976
|
+
// Sanity: a read token sees the read tools but not write/admin ones.
|
|
977
|
+
const names = (rootList as { result: { tools: { name: string }[] } }).result.tools.map(
|
|
978
|
+
(t) => t.name,
|
|
979
|
+
);
|
|
980
|
+
expect(names).toContain("query-notes");
|
|
981
|
+
expect(names).not.toContain("create-note");
|
|
982
|
+
expect(names).not.toContain("manage-token");
|
|
983
|
+
});
|
|
984
|
+
|
|
985
|
+
test("a vault-A token ALWAYS lands in A through root /mcp — never another vault's data", async () => {
|
|
986
|
+
createVault("alpha");
|
|
987
|
+
createVault("beta");
|
|
988
|
+
// Seed a secret in BETA. A token minted for ALPHA must never surface it.
|
|
989
|
+
await getVaultStore("beta").createNote("BETA-ONLY-SECRET", { path: "secret" });
|
|
990
|
+
getVaultStore("alpha");
|
|
991
|
+
const alphaToken = await mintJwt({ vaultName: "alpha", scopes: ["vault:alpha:admin"] });
|
|
992
|
+
|
|
993
|
+
const res = await mcpPost("/mcp", alphaToken, QUERY);
|
|
994
|
+
expect(res.status).toBe(200);
|
|
995
|
+
// The vault is derived from the token (alpha); beta's note is unreachable.
|
|
996
|
+
expect(JSON.stringify(await rpcFrame(res))).not.toContain("BETA-ONLY-SECRET");
|
|
997
|
+
});
|
|
998
|
+
|
|
999
|
+
test("scope and aud naming DIFFERENT vaults → 401 (disagreement, never guess)", async () => {
|
|
1000
|
+
createVault("alpha");
|
|
1001
|
+
createVault("beta");
|
|
1002
|
+
// aud=vault.alpha (from vaultName) but the scope names beta — two sources
|
|
1003
|
+
// disagree, so derivation refuses rather than picking a winner.
|
|
1004
|
+
const token = await mintJwt({ vaultName: "alpha", scopes: ["vault:beta:admin"] });
|
|
1005
|
+
const res = await mcpPost("/mcp", token, INIT);
|
|
1006
|
+
expect(res.status).toBe(401);
|
|
1007
|
+
expect(res.headers.get("WWW-Authenticate")).toContain("/.well-known/oauth-protected-resource/mcp");
|
|
1008
|
+
});
|
|
1009
|
+
|
|
1010
|
+
test("a token that names NO vault (broad scope, non-vault aud, no vault_scope) → 401", async () => {
|
|
1011
|
+
createVault("journal");
|
|
1012
|
+
// Broad `vault:read` names no vault; aud is not `vault.<name>`; no
|
|
1013
|
+
// vault_scope claim. Nothing derives a target → the discovery challenge.
|
|
1014
|
+
const token = await signRaw({ scope: "vault:read" }, "some-other-resource");
|
|
1015
|
+
const res = await mcpPost("/mcp", token, INIT);
|
|
1016
|
+
expect(res.status).toBe(401);
|
|
1017
|
+
expect(res.headers.get("WWW-Authenticate")).toContain("/.well-known/oauth-protected-resource/mcp");
|
|
1018
|
+
});
|
|
1019
|
+
|
|
1020
|
+
test("an invalid / unverifiable token → 401 + root challenge (no vault leaked)", async () => {
|
|
1021
|
+
createVault("journal");
|
|
1022
|
+
const res = await mcpPost("/mcp", "not-a-real-jwt", INIT);
|
|
1023
|
+
expect(res.status).toBe(401);
|
|
1024
|
+
expect(res.headers.get("WWW-Authenticate")).toContain("/.well-known/oauth-protected-resource/mcp");
|
|
1025
|
+
});
|
|
1026
|
+
|
|
1027
|
+
test("a validly-signed token naming a vault absent on THIS server → 404 (mirrors per-vault)", async () => {
|
|
1028
|
+
createVault("journal");
|
|
1029
|
+
// Token is real (signed by the fixture hub) but names a vault that isn't
|
|
1030
|
+
// installed here. The per-vault URL 404s for a missing vault; root matches.
|
|
1031
|
+
const token = await mintJwt({ vaultName: "ghost", scopes: ["vault:ghost:admin"] });
|
|
1032
|
+
const res = await mcpPost("/mcp", token, INIT);
|
|
1033
|
+
expect(res.status).toBe(404);
|
|
1034
|
+
expect(((await res.json()) as { error: string }).error).toBe("Vault not found");
|
|
1035
|
+
});
|
|
1036
|
+
});
|
|
1037
|
+
|
|
813
1038
|
// ---------------------------------------------------------------------------
|
|
814
1039
|
// Per-vault OAuth discovery (RFC 8414 / RFC 9728, path-append form).
|
|
815
1040
|
//
|
package/src/routing.ts
CHANGED
|
@@ -1,15 +1,23 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* HTTP request router for the multi-vault server.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* Per-vault resources live under `/vault/<name>/...` — a request names the
|
|
5
|
+
* vault it targets in the URL. The one exception is the canonical root `/mcp`
|
|
6
|
+
* endpoint (U1), which is vault-AGNOSTIC: the vault is derived from the token,
|
|
7
|
+
* not the URL. A fresh install creates a vault named `default`, so
|
|
8
|
+
* `/vault/default/...` is the baseline URL for single-vault deployments.
|
|
8
9
|
*
|
|
9
10
|
* Dispatch shape:
|
|
10
11
|
*
|
|
11
12
|
* /.well-known/parachute.json — NOT served here (CLI owns it at
|
|
12
13
|
* origin root; vault never handles it)
|
|
14
|
+
* /.well-known/oauth-protected-resource/mcp
|
|
15
|
+
* — root-endpoint PRM (RFC 9728) for the
|
|
16
|
+
* canonical `/mcp` below (U1)
|
|
17
|
+
* /mcp[/*] — canonical root MCP endpoint; the vault
|
|
18
|
+
* is DERIVED FROM THE TOKEN, then the
|
|
19
|
+
* request re-dispatches through the same
|
|
20
|
+
* per-vault machinery (U1)
|
|
13
21
|
* /health — liveness ping, vault names leaked
|
|
14
22
|
* only to authenticated callers
|
|
15
23
|
* /vaults/list — public vault-name discovery (can be
|
|
@@ -21,15 +29,20 @@
|
|
|
21
29
|
* /vault/<name>/.well-known/oauth-* — discovery forwarder; metadata names
|
|
22
30
|
* the hub as the authorization server
|
|
23
31
|
* (vault is resource-server only)
|
|
24
|
-
* /vault/<name>/mcp[/*] — MCP endpoint (Bearer auth)
|
|
32
|
+
* /vault/<name>/mcp[/*] — MCP endpoint (Bearer auth); the
|
|
33
|
+
* URL-addressed twin of root `/mcp`,
|
|
34
|
+
* untouched and permanent
|
|
25
35
|
* /vault/<name>/view/<idOrPath> — auth-aware HTML view
|
|
26
36
|
* /vault/<name>/public/<noteId> — legacy alias → /view redirect
|
|
27
37
|
* /vault/<name> — vault metadata + stats (auth)
|
|
28
38
|
* /vault/<name>/api/... — REST surface (auth)
|
|
29
39
|
*
|
|
30
|
-
*
|
|
31
|
-
* `/
|
|
32
|
-
*
|
|
40
|
+
* The root `/mcp` here is NOT the old unscoped compat prefix (that pre-0.5.0
|
|
41
|
+
* `/mcp` — an alias for a single default vault — is gone). It's the token-
|
|
42
|
+
* derived door: same auth machinery as the per-vault endpoint, reached without
|
|
43
|
+
* the client having to know the vault's URL name. There is still deliberately
|
|
44
|
+
* no compat for the old `/api/*`, `/oauth/*`, `/view/*`, or `/vaults/<name>/*`
|
|
45
|
+
* prefixes; clients using those must re-authenticate and point at the new URLs.
|
|
33
46
|
*
|
|
34
47
|
* **No standalone OAuth issuer.** vault does not mint OAuth tokens or
|
|
35
48
|
* render a consent UI. Hub is the issuer; vault validates hub-signed
|
|
@@ -50,6 +63,7 @@ import {
|
|
|
50
63
|
import {
|
|
51
64
|
authenticateVaultRequest,
|
|
52
65
|
authenticateGlobalRequest,
|
|
66
|
+
deriveVaultFromToken,
|
|
53
67
|
extractApiKey,
|
|
54
68
|
} from "./auth.ts";
|
|
55
69
|
import { hasScopeForVault, hasMigrateScopeForVault, SCOPE_ADMIN, SCOPE_READ, scopeForMethod, verbForMethod } from "./scopes.ts";
|
|
@@ -78,6 +92,7 @@ import { handleTriggers } from "./triggers-api.ts";
|
|
|
78
92
|
import { expandTokenTagScope } from "./tag-scope.ts";
|
|
79
93
|
import {
|
|
80
94
|
handleProtectedResource,
|
|
95
|
+
handleRootProtectedResource,
|
|
81
96
|
handleAuthorizationServer,
|
|
82
97
|
getBaseUrl,
|
|
83
98
|
} from "./oauth-discovery.ts";
|
|
@@ -135,6 +150,58 @@ async function withMcpChallenge(
|
|
|
135
150
|
return new Response(body, { status: 401, headers });
|
|
136
151
|
}
|
|
137
152
|
|
|
153
|
+
/**
|
|
154
|
+
* 401 + RFC 9728 challenge for the canonical ROOT `/mcp` endpoint (U1), when
|
|
155
|
+
* the token is absent / invalid / names no single vault. Mirrors
|
|
156
|
+
* `withMcpChallenge` but points at the ROOT protected-resource metadata
|
|
157
|
+
* (`/.well-known/oauth-protected-resource/mcp`) rather than a vault-scoped one,
|
|
158
|
+
* since the root resource is vault-agnostic. A spec-following MCP client
|
|
159
|
+
* follows this pointer to the root PRM, discovers the hub, and requests the
|
|
160
|
+
* un-narrowed scopes the hub's consent picker narrows to a chosen vault.
|
|
161
|
+
*/
|
|
162
|
+
function rootMcpChallenge(req: Request): Response {
|
|
163
|
+
const base = getBaseUrl(req);
|
|
164
|
+
return Response.json(
|
|
165
|
+
{ error: "Unauthorized", message: "API key required" },
|
|
166
|
+
{
|
|
167
|
+
status: 401,
|
|
168
|
+
headers: {
|
|
169
|
+
"WWW-Authenticate": `Bearer resource_metadata="${base}/.well-known/oauth-protected-resource/mcp"`,
|
|
170
|
+
},
|
|
171
|
+
},
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Shared scoped-MCP dispatch tail used by BOTH the URL-addressed per-vault
|
|
177
|
+
* endpoint (`/vault/<name>/mcp`) and the token-derived root endpoint (`/mcp`,
|
|
178
|
+
* U1). Runs the FULL per-vault auth machinery via `authenticateVaultRequest`
|
|
179
|
+
* (audience strict-check, `vault_scope` pin, broad-scope rejection, tag-scope
|
|
180
|
+
* parse) and, on success, hands off to `handleScopedMcp`. A 401 is decorated
|
|
181
|
+
* with the per-vault RFC 9728 challenge. Because both entry points funnel
|
|
182
|
+
* through this one function, a root request behaves byte-identically to the
|
|
183
|
+
* same token at the per-vault URL — and, for the root, the re-validation here
|
|
184
|
+
* is the authoritative gate: a derivation that named the wrong vault fails the
|
|
185
|
+
* audience strict-check, it never bypasses it.
|
|
186
|
+
*/
|
|
187
|
+
async function dispatchScopedMcp(
|
|
188
|
+
req: Request,
|
|
189
|
+
vaultName: string,
|
|
190
|
+
vaultConfig: VaultConfig,
|
|
191
|
+
): Promise<Response> {
|
|
192
|
+
const auth = await authenticateVaultRequest(req, vaultConfig);
|
|
193
|
+
if ("error" in auth) return withMcpChallenge(auth.error, req, vaultName);
|
|
194
|
+
// Thread the RAW caller bearer (the exact credential the session presented)
|
|
195
|
+
// into the MCP layer so the manage-token tool can forward it to hub's
|
|
196
|
+
// mint-token attenuation proxy (vault#403, MGT). Only the raw validated
|
|
197
|
+
// bearer — never a fabricated one. extractApiKey returns the same value
|
|
198
|
+
// `authenticateVaultRequest` validated above; non-forwardable credentials
|
|
199
|
+
// (env-var secret, legacy pvt_*) are handled by manage-token itself (it only
|
|
200
|
+
// forwards JWT-shaped bearers).
|
|
201
|
+
const callerBearer = extractApiKey(req);
|
|
202
|
+
return handleScopedMcp(req, vaultName, auth, callerBearer);
|
|
203
|
+
}
|
|
204
|
+
|
|
138
205
|
/**
|
|
139
206
|
* Check if a /view request has a valid API key (header or ?key= query param).
|
|
140
207
|
* Returns true if authenticated, false if not. Never rejects — unauthenticated
|
|
@@ -260,6 +327,18 @@ export async function route(
|
|
|
260
327
|
return handleAuthorizationServer(req, vaultName);
|
|
261
328
|
}
|
|
262
329
|
|
|
330
|
+
// Root-endpoint protected-resource metadata (RFC 9728 §3.1 path-insertion)
|
|
331
|
+
// for the canonical `/mcp` endpoint (U1). The root resource is vault-
|
|
332
|
+
// agnostic — the target vault is derived from the token, not the URL — so
|
|
333
|
+
// this document carries no vault name and advertises the un-narrowed
|
|
334
|
+
// `vault:read` / `vault:write` scopes; the hub's consent picker narrows them
|
|
335
|
+
// to a chosen vault at authorization time. Distinct from the per-vault
|
|
336
|
+
// insertion shapes above (which require a `/vault/<name>` segment), so there
|
|
337
|
+
// is no overlap. The `rootMcpChallenge` 401 points clients here.
|
|
338
|
+
if (path === "/.well-known/oauth-protected-resource/mcp") {
|
|
339
|
+
return handleRootProtectedResource(req);
|
|
340
|
+
}
|
|
341
|
+
|
|
263
342
|
// ---------------------------------------------------------------------
|
|
264
343
|
// Cross-vault / origin-root endpoints
|
|
265
344
|
// ---------------------------------------------------------------------
|
|
@@ -368,6 +447,43 @@ export async function route(
|
|
|
368
447
|
return Response.json({ vaults });
|
|
369
448
|
}
|
|
370
449
|
|
|
450
|
+
// ---------------------------------------------------------------------
|
|
451
|
+
// Canonical root MCP endpoint — token-derived vault dispatch (U1).
|
|
452
|
+
//
|
|
453
|
+
// `POST /mcp` (plus the GET/DELETE the streamable-HTTP transport uses, and
|
|
454
|
+
// the `/mcp/*` subpaths) is the vault-AGNOSTIC entry point: the target vault
|
|
455
|
+
// is derived from the TOKEN, not the URL. We validate the bearer with the
|
|
456
|
+
// same scope-guard trust kernel the per-vault path uses, read the vault name
|
|
457
|
+
// from the token's claims, then RE-DISPATCH through the identical per-vault
|
|
458
|
+
// machinery (`dispatchScopedMcp` → `authenticateVaultRequest` →
|
|
459
|
+
// `handleScopedMcp`). The re-dispatch re-validates WITH the audience pin, so
|
|
460
|
+
// a bad derivation fails the inner check rather than bypassing it (defense in
|
|
461
|
+
// depth). The legacy URL-addressed `/vault/<name>/mcp` is untouched and lives
|
|
462
|
+
// forever.
|
|
463
|
+
//
|
|
464
|
+
// No / invalid / unnameable token → 401 whose `WWW-Authenticate` points at
|
|
465
|
+
// the ROOT PRM (`/.well-known/oauth-protected-resource/mcp`), so a spec-
|
|
466
|
+
// following MCP client discovers the hub and requests the un-narrowed scopes
|
|
467
|
+
// its consent picker narrows to a chosen vault.
|
|
468
|
+
if (path === "/mcp" || path.startsWith("/mcp/")) {
|
|
469
|
+
const derived = await deriveVaultFromToken(req);
|
|
470
|
+
if ("error" in derived) {
|
|
471
|
+
return rootMcpChallenge(req);
|
|
472
|
+
}
|
|
473
|
+
const vaultConfig = readVaultConfig(derived.vaultName);
|
|
474
|
+
if (!vaultConfig) {
|
|
475
|
+
// A validly-signed hub token naming a vault that isn't on THIS server.
|
|
476
|
+
// Mirror the per-vault 404 (`/vault/<name>/mcp` on a missing vault) — the
|
|
477
|
+
// holder knows which vault their token targets; nothing is leaked to an
|
|
478
|
+
// anonymous caller (they never validate a token to reach here).
|
|
479
|
+
return Response.json(
|
|
480
|
+
{ error: "Vault not found", vault: derived.vaultName },
|
|
481
|
+
{ status: 404 },
|
|
482
|
+
);
|
|
483
|
+
}
|
|
484
|
+
return dispatchScopedMcp(req, derived.vaultName, vaultConfig);
|
|
485
|
+
}
|
|
486
|
+
|
|
371
487
|
// ---------------------------------------------------------------------
|
|
372
488
|
// Per-vault routing: /vault/<name>/...
|
|
373
489
|
// ---------------------------------------------------------------------
|
|
@@ -507,28 +623,24 @@ export async function route(
|
|
|
507
623
|
return handleTicketSpend(req, ticketId, vaultName, store);
|
|
508
624
|
}
|
|
509
625
|
|
|
626
|
+
// MCP (per-vault, single-vault session) — the URL names the vault. Shares
|
|
627
|
+
// the exact dispatch tail with the token-derived root `/mcp` (U1), so both
|
|
628
|
+
// behave identically. Handled BEFORE the shared authenticated-surface auth
|
|
629
|
+
// below because `dispatchScopedMcp` owns its own auth + 401 challenge; a
|
|
630
|
+
// non-MCP 401 further down never carries the RFC 9728 challenge (it's
|
|
631
|
+
// MCP-only).
|
|
632
|
+
if (subpath === "/mcp" || subpath.startsWith("/mcp/")) {
|
|
633
|
+
return dispatchScopedMcp(req, vaultName, vaultConfig);
|
|
634
|
+
}
|
|
635
|
+
|
|
510
636
|
// ---------------------------------------------------------------------
|
|
511
637
|
// Authenticated surface
|
|
512
638
|
// ---------------------------------------------------------------------
|
|
513
639
|
|
|
514
640
|
const store = getVaultStore(vaultName);
|
|
515
641
|
const auth = await authenticateVaultRequest(req, vaultConfig);
|
|
516
|
-
const isScopedMcp = subpath === "/mcp" || subpath.startsWith("/mcp/");
|
|
517
642
|
if ("error" in auth) {
|
|
518
|
-
return
|
|
519
|
-
}
|
|
520
|
-
|
|
521
|
-
// MCP (per-vault, single-vault session).
|
|
522
|
-
if (isScopedMcp) {
|
|
523
|
-
// Thread the RAW caller bearer (the exact credential the session
|
|
524
|
-
// presented) into the MCP layer so the manage-token tool can forward it
|
|
525
|
-
// to hub's mint-token attenuation proxy (vault#403, MGT). Only the raw
|
|
526
|
-
// validated bearer — never a fabricated one. extractApiKey returns the
|
|
527
|
-
// same value `authenticateVaultRequest` validated above; non-forwardable
|
|
528
|
-
// credentials (env-var secret, legacy pvt_*) are handled by manage-token
|
|
529
|
-
// itself (it only forwards JWT-shaped bearers).
|
|
530
|
-
const callerBearer = extractApiKey(req);
|
|
531
|
-
return handleScopedMcp(req, vaultName, auth, callerBearer);
|
|
643
|
+
return auth.error;
|
|
532
644
|
}
|
|
533
645
|
|
|
534
646
|
// Bare `/vault/<name>` — single-vault root. Returns name, description,
|
package/src/scopes.ts
CHANGED
|
@@ -263,6 +263,28 @@ export function validateMintedScopes(
|
|
|
263
263
|
return { ok: true };
|
|
264
264
|
}
|
|
265
265
|
|
|
266
|
+
/**
|
|
267
|
+
* Distinct vault names NAMED by narrowed `vault:<name>:<verb>` scopes in the
|
|
268
|
+
* granted list. Broad `vault:<verb>` scopes name no vault (a legacy/operator
|
|
269
|
+
* shape) and are skipped; the `migrate` axis isn't a read/write/admin verb so
|
|
270
|
+
* `decomposeVaultScope` returns null for it and it too is skipped.
|
|
271
|
+
*
|
|
272
|
+
* Used by the canonical root `/mcp` endpoint (U1) to read the target vault
|
|
273
|
+
* from a token's scope claim — one of three agreeing sources (scope / `aud` /
|
|
274
|
+
* single-element `vault_scope`) that `deriveVaultFromToken` cross-checks. A
|
|
275
|
+
* hub-minted token carries scopes for exactly one vault, so this returns a
|
|
276
|
+
* single-element list in practice; a return of length ≠ 1 signals a
|
|
277
|
+
* malformed/multi-vault scope set the derivation treats as a disagreement.
|
|
278
|
+
*/
|
|
279
|
+
export function narrowedVaultNames(granted: string[]): string[] {
|
|
280
|
+
const names = new Set<string>();
|
|
281
|
+
for (const s of granted) {
|
|
282
|
+
const d = decomposeVaultScope(s);
|
|
283
|
+
if (d && d.vault !== null) names.add(d.vault);
|
|
284
|
+
}
|
|
285
|
+
return [...names];
|
|
286
|
+
}
|
|
287
|
+
|
|
266
288
|
/**
|
|
267
289
|
* Detect a broad `vault:<verb>` scope in a granted list. Hub-issued JWTs
|
|
268
290
|
* must NOT carry broad vault scopes — the hub mints `vault:<name>:<verb>` so
|
|
@@ -1627,3 +1627,154 @@ describe("transcription worker — legacy in-body memo content safety (finding F
|
|
|
1627
1627
|
expect(note!.content).toContain("retry text with $& and $0 and $$ literal");
|
|
1628
1628
|
});
|
|
1629
1629
|
});
|
|
1630
|
+
|
|
1631
|
+
// ---------------------------------------------------------------------------
|
|
1632
|
+
// voice W2 — segmented recordings. The app slices a long recording into
|
|
1633
|
+
// ~10-min segments, each linked as its own attachment on ONE note carrying a
|
|
1634
|
+
// client-set `segment_index` (0-based). The legacy in-body path targets that
|
|
1635
|
+
// part's markers — `_Transcript pending (part N)._` / `_Transcription
|
|
1636
|
+
// unavailable (part N)._`, N = segment_index + 1 — so each transcript lands in
|
|
1637
|
+
// its own pre-allocated slot regardless of completion order. Marker strings
|
|
1638
|
+
// are a BYTE-EXACT cross-door + cross-repo contract (the cloud worker ships
|
|
1639
|
+
// the identical text), so these assertions pin the exact bytes.
|
|
1640
|
+
// ---------------------------------------------------------------------------
|
|
1641
|
+
|
|
1642
|
+
describe("transcription worker — segmented recordings (voice W2)", () => {
|
|
1643
|
+
test("three parts completing OUT OF ORDER (2 ok, 0 fails, 1 ok) each land in their own slot", async () => {
|
|
1644
|
+
// One note, three pre-allocated part slots + the shared stub opt-in.
|
|
1645
|
+
const body =
|
|
1646
|
+
"# 🎙️ Voice memo\n\n" +
|
|
1647
|
+
"_Transcript pending (part 1)._\n\n" +
|
|
1648
|
+
"_Transcript pending (part 2)._\n\n" +
|
|
1649
|
+
"_Transcript pending (part 3)._\n";
|
|
1650
|
+
await store.createNote(body, { id: "seg-note", metadata: { transcribe_stub: true } });
|
|
1651
|
+
|
|
1652
|
+
seedAudio("memos/seg0.webm");
|
|
1653
|
+
seedAudio("memos/seg1.webm");
|
|
1654
|
+
seedAudio("memos/seg2.webm");
|
|
1655
|
+
const seg0 = await store.addAttachment("seg-note", "memos/seg0.webm", "audio/webm", {
|
|
1656
|
+
transcribe_status: "pending",
|
|
1657
|
+
segment_index: 0,
|
|
1658
|
+
});
|
|
1659
|
+
const seg1 = await store.addAttachment("seg-note", "memos/seg1.webm", "audio/webm", {
|
|
1660
|
+
transcribe_status: "pending",
|
|
1661
|
+
segment_index: 1,
|
|
1662
|
+
});
|
|
1663
|
+
const seg2 = await store.addAttachment("seg-note", "memos/seg2.webm", "audio/webm", {
|
|
1664
|
+
transcribe_status: "pending",
|
|
1665
|
+
segment_index: 2,
|
|
1666
|
+
});
|
|
1667
|
+
|
|
1668
|
+
// Complete part 3 (segment_index 2) FIRST — success.
|
|
1669
|
+
const w2 = makeWorker({ fetchImpl: mkFetchMock([{ text: "part three text" }]) });
|
|
1670
|
+
try { await w2.kick("default", seg2); } finally { await w2.stop(); }
|
|
1671
|
+
|
|
1672
|
+
// Then part 1 (segment_index 0) — terminal failure (maxAttempts=1).
|
|
1673
|
+
const w0 = makeWorker({
|
|
1674
|
+
fetchImpl: mkFetchMock([{ error: "scribe down", status: 500 }]),
|
|
1675
|
+
maxAttempts: 1,
|
|
1676
|
+
});
|
|
1677
|
+
try { await w0.kick("default", seg0); } finally { await w0.stop(); }
|
|
1678
|
+
|
|
1679
|
+
// Finally part 2 (segment_index 1) — success.
|
|
1680
|
+
const w1 = makeWorker({ fetchImpl: mkFetchMock([{ text: "part two text" }]) });
|
|
1681
|
+
try { await w1.kick("default", seg1); } finally { await w1.stop(); }
|
|
1682
|
+
|
|
1683
|
+
const note = await store.getNote("seg-note");
|
|
1684
|
+
// Each part landed in its own slot: part 1 → failure marker, part 2/3 →
|
|
1685
|
+
// their transcripts, despite completing 3 → 1 → 2.
|
|
1686
|
+
expect(note!.content).toBe(
|
|
1687
|
+
"# 🎙️ Voice memo\n\n" +
|
|
1688
|
+
"_Transcription unavailable (part 1)._\n\n" +
|
|
1689
|
+
"part two text\n\n" +
|
|
1690
|
+
"part three text\n",
|
|
1691
|
+
);
|
|
1692
|
+
// Shared stub SURVIVES — sibling parts each needed the gate open; a
|
|
1693
|
+
// per-part clear (the un-segmented behavior) would have blocked parts 2 & 3.
|
|
1694
|
+
expect((note!.metadata as any)?.transcribe_stub).toBe(true);
|
|
1695
|
+
|
|
1696
|
+
// Attachment rows reflect their individual outcomes.
|
|
1697
|
+
const atts = await store.getAttachments("seg-note");
|
|
1698
|
+
const byIndex = Object.fromEntries(atts.map((a) => [a.metadata?.segment_index, a]));
|
|
1699
|
+
expect(byIndex[0]!.metadata?.transcribe_status).toBe("failed");
|
|
1700
|
+
expect(byIndex[1]!.metadata?.transcribe_status).toBe("done");
|
|
1701
|
+
expect(byIndex[1]!.metadata?.transcript).toBe("part two text");
|
|
1702
|
+
expect(byIndex[2]!.metadata?.transcribe_status).toBe("done");
|
|
1703
|
+
expect(byIndex[2]!.metadata?.transcript).toBe("part three text");
|
|
1704
|
+
});
|
|
1705
|
+
|
|
1706
|
+
test("un-segmented attachment → BARE markers, one-shot stub cleared (regression pin)", async () => {
|
|
1707
|
+
// No `segment_index` → byte-for-byte the pre-W2 behavior: replace the bare
|
|
1708
|
+
// placeholder, clear the stub. A stray `(part N)` marker in the body is
|
|
1709
|
+
// left untouched (the bare path never targets part markers).
|
|
1710
|
+
await store.createNote(
|
|
1711
|
+
"# 🎙️ Voice memo\n\n_Transcript pending._\n\n_Transcript pending (part 2)._\n",
|
|
1712
|
+
{ id: "unseg-note", metadata: { transcribe_stub: true } },
|
|
1713
|
+
);
|
|
1714
|
+
seedAudio("memos/unseg.webm");
|
|
1715
|
+
const att = await store.addAttachment("unseg-note", "memos/unseg.webm", "audio/webm", {
|
|
1716
|
+
transcribe_status: "pending",
|
|
1717
|
+
});
|
|
1718
|
+
|
|
1719
|
+
const worker = makeWorker({ fetchImpl: mkFetchMock([{ text: "bare transcript" }]) });
|
|
1720
|
+
try { await worker.kick("default", att); } finally { await worker.stop(); }
|
|
1721
|
+
|
|
1722
|
+
const note = await store.getNote("unseg-note");
|
|
1723
|
+
// Bare marker replaced; the part-2 marker is NOT touched by the bare path.
|
|
1724
|
+
expect(note!.content).toBe(
|
|
1725
|
+
"# 🎙️ Voice memo\n\nbare transcript\n\n_Transcript pending (part 2)._\n",
|
|
1726
|
+
);
|
|
1727
|
+
// One-shot stub cleared, exactly as today.
|
|
1728
|
+
expect((note!.metadata as any)?.transcribe_stub).toBeUndefined();
|
|
1729
|
+
});
|
|
1730
|
+
|
|
1731
|
+
test("malformed segment_index falls back to bare markers (contract: integer ≥ 0)", async () => {
|
|
1732
|
+
// A non-integer / negative `segment_index` must NOT fabricate a `(part N)`
|
|
1733
|
+
// marker — it degrades to the bare path (fully backward compatible).
|
|
1734
|
+
await store.createNote(
|
|
1735
|
+
"# 🎙️ Voice memo\n\n_Transcript pending._\n",
|
|
1736
|
+
{ id: "seg-bad", metadata: { transcribe_stub: true } },
|
|
1737
|
+
);
|
|
1738
|
+
seedAudio("memos/segbad.webm");
|
|
1739
|
+
const att = await store.addAttachment("seg-bad", "memos/segbad.webm", "audio/webm", {
|
|
1740
|
+
transcribe_status: "pending",
|
|
1741
|
+
segment_index: -1, // invalid → bare path
|
|
1742
|
+
});
|
|
1743
|
+
|
|
1744
|
+
const worker = makeWorker({ fetchImpl: mkFetchMock([{ text: "fallback transcript" }]) });
|
|
1745
|
+
try { await worker.kick("default", att); } finally { await worker.stop(); }
|
|
1746
|
+
|
|
1747
|
+
const note = await store.getNote("seg-bad");
|
|
1748
|
+
expect(note!.content).toBe("# 🎙️ Voice memo\n\nfallback transcript\n");
|
|
1749
|
+
expect((note!.metadata as any)?.transcribe_stub).toBeUndefined();
|
|
1750
|
+
});
|
|
1751
|
+
|
|
1752
|
+
test("segmented part whose marker was edited away → transcript appended (graceful fallback)", async () => {
|
|
1753
|
+
// The user rewrote part 2's slot by hand, removing its pending marker.
|
|
1754
|
+
// Mirror the un-segmented replace-or-append policy scoped to the part:
|
|
1755
|
+
// with neither of part 2's markers present, append the transcript
|
|
1756
|
+
// (no `(part N)` prefix) rather than destroying the user's edit.
|
|
1757
|
+
const editedBody =
|
|
1758
|
+
"# 🎙️ Voice memo\n\n" +
|
|
1759
|
+
"_Transcript pending (part 1)._\n\n" +
|
|
1760
|
+
"User rewrote part two by hand.\n";
|
|
1761
|
+
await store.createNote(editedBody, { id: "seg-edit", metadata: { transcribe_stub: true } });
|
|
1762
|
+
seedAudio("memos/segedit.webm");
|
|
1763
|
+
const seg1 = await store.addAttachment("seg-edit", "memos/segedit.webm", "audio/webm", {
|
|
1764
|
+
transcribe_status: "pending",
|
|
1765
|
+
segment_index: 1, // part 2
|
|
1766
|
+
});
|
|
1767
|
+
|
|
1768
|
+
const worker = makeWorker({ fetchImpl: mkFetchMock([{ text: "the real part two" }]) });
|
|
1769
|
+
try { await worker.kick("default", seg1); } finally { await worker.stop(); }
|
|
1770
|
+
|
|
1771
|
+
const note = await store.getNote("seg-edit");
|
|
1772
|
+
// Part 2's transcript appended; the user's edit + part 1's pending slot
|
|
1773
|
+
// both survive untouched.
|
|
1774
|
+
expect(note!.content).toBe(`${editedBody}\n\nthe real part two`);
|
|
1775
|
+
expect(note!.content).toContain("User rewrote part two by hand.");
|
|
1776
|
+
expect(note!.content).toContain("_Transcript pending (part 1)._");
|
|
1777
|
+
// Segmented → shared stub preserved (part 1 still needs the gate open).
|
|
1778
|
+
expect((note!.metadata as any)?.transcribe_stub).toBe(true);
|
|
1779
|
+
});
|
|
1780
|
+
});
|
|
@@ -64,41 +64,57 @@ import {
|
|
|
64
64
|
} from "../core/src/transcription/provider.ts";
|
|
65
65
|
import { ScribeHttpProvider } from "./transcription/providers/scribe-http.ts";
|
|
66
66
|
|
|
67
|
-
/** Placeholder pattern written by the voice-memo capture stub. */
|
|
68
|
-
const TRANSCRIPT_PLACEHOLDER = /_Transcript pending\._/;
|
|
69
|
-
|
|
70
67
|
/**
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
68
|
+
* The in-body transcription markers.
|
|
69
|
+
*
|
|
70
|
+
* The BARE markers are the un-segmented default; voice W2 (segmented
|
|
71
|
+
* recordings) targets per-part variants built by `markersFor`. Both are a
|
|
72
|
+
* BYTE-EXACT cross-door + cross-repo contract — the cloud Workers-AI
|
|
73
|
+
* transcription path ships the identical strings, and the notes-ui status
|
|
74
|
+
* chip (parachute-surface TranscriptionStatus.tsx) keys off the failure
|
|
75
|
+
* marker's exact copy. Don't change any of this text without a coordinated
|
|
76
|
+
* change in both places. A friendlier "retry available" copy + chip
|
|
77
|
+
* affordance is a tracked parachute-surface follow-up.
|
|
76
78
|
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
* tracked parachute-surface follow-up.
|
|
79
|
+
* Owning the failure marker here (it used to be written by Lens's now-removed
|
|
80
|
+
* scribe client) means a failed upload stops reading "Transcript pending"
|
|
81
|
+
* forever regardless of which client uploaded the audio.
|
|
81
82
|
*/
|
|
82
|
-
const
|
|
83
|
+
const BARE_PENDING = "_Transcript pending._";
|
|
84
|
+
const BARE_UNAVAILABLE = "_Transcription unavailable._";
|
|
85
|
+
|
|
86
|
+
/** Escape a literal string for safe embedding in a `RegExp`. */
|
|
87
|
+
function escapeRegExp(literal: string): string {
|
|
88
|
+
return literal.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
89
|
+
}
|
|
83
90
|
|
|
84
91
|
/**
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
* the `_Recorded …_` line, the header).
|
|
92
|
-
*
|
|
93
|
-
* Deliberately NO `/g` flag — `.replace` swaps only the FIRST match. A
|
|
94
|
-
* canonical capture body holds exactly one marker, so first-match is the
|
|
95
|
-
* correct target. `applyFailureMarker`'s includes-guard (no-op when the
|
|
96
|
-
* marker is already present) prevents markers accumulating across repeated
|
|
97
|
-
* terminal failures, so the body never carries two of the same marker. A
|
|
98
|
-
* hand-edited body that somehow contains both markers patches only the
|
|
99
|
-
* first — accepted (degenerate, operator-induced).
|
|
92
|
+
* The pending + terminal-failure markers for an attachment, honoring
|
|
93
|
+
* `segment_index` (voice W2 — segmented recordings). `undefined` yields the
|
|
94
|
+
* bare markers (fully backward compatible — un-segmented flows are byte-
|
|
95
|
+
* unchanged). An integer ≥ 0 yields this part's markers, with the human-
|
|
96
|
+
* facing part number N = segment_index + 1 (1-based, decimal):
|
|
97
|
+
* `_Transcript pending (part N)._` / `_Transcription unavailable (part N)._`
|
|
100
98
|
*/
|
|
101
|
-
|
|
99
|
+
function markersFor(segmentIndex: number | undefined): { pending: string; unavailable: string } {
|
|
100
|
+
if (segmentIndex === undefined) return { pending: BARE_PENDING, unavailable: BARE_UNAVAILABLE };
|
|
101
|
+
const n = segmentIndex + 1;
|
|
102
|
+
return {
|
|
103
|
+
pending: `_Transcript pending (part ${n})._`,
|
|
104
|
+
unavailable: `_Transcription unavailable (part ${n})._`,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* A valid segment index (integer ≥ 0) off attachment metadata, else
|
|
110
|
+
* `undefined` — the un-segmented path. Client-set at link time; anything that
|
|
111
|
+
* isn't a non-negative integer falls back to the bare markers rather than
|
|
112
|
+
* fabricating a `(part N)`.
|
|
113
|
+
*/
|
|
114
|
+
function segmentIndexOf(meta: { segment_index?: unknown }): number | undefined {
|
|
115
|
+
const raw = meta.segment_index;
|
|
116
|
+
return typeof raw === "number" && Number.isInteger(raw) && raw >= 0 ? raw : undefined;
|
|
117
|
+
}
|
|
102
118
|
|
|
103
119
|
/**
|
|
104
120
|
* Default sweep cadence (ms). The sweep is the safety net for backoff-
|
|
@@ -195,6 +211,14 @@ interface PendingMeta {
|
|
|
195
211
|
* worker preserves the original stub-patching behavior (Lens flow).
|
|
196
212
|
*/
|
|
197
213
|
transcribe_origin?: "auto" | "legacy";
|
|
214
|
+
/**
|
|
215
|
+
* Voice W2 (segmented recordings): a client-set 0-based index marking this
|
|
216
|
+
* attachment as one segment of a longer recording sliced into ~10-min parts,
|
|
217
|
+
* all linked on ONE note. When present, the legacy in-body path targets this
|
|
218
|
+
* part's markers (`… (part N)._`, N = segment_index + 1) rather than the bare
|
|
219
|
+
* ones — making per-part ordering structurally guaranteed. See `markersFor`.
|
|
220
|
+
*/
|
|
221
|
+
segment_index?: number;
|
|
198
222
|
[k: string]: unknown;
|
|
199
223
|
}
|
|
200
224
|
|
|
@@ -357,17 +381,28 @@ export function startTranscriptionWorker(opts: TranscriptionWorkerOpts): Transcr
|
|
|
357
381
|
* attachment failure we're trying to record.
|
|
358
382
|
*
|
|
359
383
|
* Body policy (finding F — never destroy content):
|
|
360
|
-
* -
|
|
361
|
-
*
|
|
362
|
-
*
|
|
363
|
-
*
|
|
364
|
-
* -
|
|
365
|
-
*
|
|
366
|
-
*
|
|
367
|
-
*
|
|
368
|
-
*
|
|
384
|
+
* - Pending marker PRESENT → surgical replace of the pending marker with
|
|
385
|
+
* the failure marker. The `![[memo]]` embed + any surrounding text
|
|
386
|
+
* survive. For a segmented attachment (`segment_index` set) this is the
|
|
387
|
+
* per-part `_Transcript pending (part N)._`; otherwise the bare marker.
|
|
388
|
+
* - Failure marker ALREADY PRESENT → no-op (idempotent; a double-terminal-
|
|
389
|
+
* failure must not stack markers).
|
|
390
|
+
* - Otherwise (pending marker absent — the user edited the note while it
|
|
391
|
+
* was pending) → APPEND `\n\n` + failure marker to the existing content.
|
|
392
|
+
* The old code full-replaced the body here, destroying the embed AND the
|
|
393
|
+
* user's edits. We append instead so nothing is lost. If the content is
|
|
394
|
+
* empty, the marker alone becomes the body (avoids a leading blank line).
|
|
369
395
|
*/
|
|
370
|
-
async function applyFailureMarker(
|
|
396
|
+
async function applyFailureMarker(
|
|
397
|
+
store: Store,
|
|
398
|
+
noteId: string,
|
|
399
|
+
segmentIndex: number | undefined,
|
|
400
|
+
): Promise<void> {
|
|
401
|
+
// Bare markers by default; this segment's `(part N)` markers when the
|
|
402
|
+
// attachment carries a `segment_index` (voice W2). String-search replace
|
|
403
|
+
// targets the FIRST occurrence (a canonical body holds exactly one), and
|
|
404
|
+
// the includes-guard below keeps a repeated terminal failure from stacking.
|
|
405
|
+
const { pending, unavailable } = markersFor(segmentIndex);
|
|
371
406
|
// OC-guarded (vault#435): the read-transform-write below is re-run against
|
|
372
407
|
// fresh content on a conflict so a concurrent user edit isn't clobbered.
|
|
373
408
|
// The transform is pure w.r.t. the note it's handed; the stub-set and
|
|
@@ -381,17 +416,24 @@ export function startTranscriptionWorker(opts: TranscriptionWorkerOpts): Transcr
|
|
|
381
416
|
if (noteMeta.transcribe_stub !== true) return null;
|
|
382
417
|
|
|
383
418
|
let body: string;
|
|
384
|
-
if (
|
|
385
|
-
|
|
386
|
-
|
|
419
|
+
if (note.content.includes(pending)) {
|
|
420
|
+
// Function replacer so the search string is treated literally and the
|
|
421
|
+
// (fixed) failure marker is inserted verbatim.
|
|
422
|
+
body = note.content.replace(pending, () => unavailable);
|
|
423
|
+
} else if (note.content.includes(unavailable)) {
|
|
387
424
|
// Marker already present — nothing to do. Clear the stub and
|
|
388
425
|
// return without rewriting the body so we don't stack markers.
|
|
389
426
|
body = note.content;
|
|
390
427
|
} else {
|
|
391
428
|
body = note.content.length > 0
|
|
392
|
-
? `${note.content}\n\n${
|
|
393
|
-
:
|
|
429
|
+
? `${note.content}\n\n${unavailable}`
|
|
430
|
+
: unavailable;
|
|
394
431
|
}
|
|
432
|
+
// Segmented: the stub is SHARED across this note's parts — keep it set
|
|
433
|
+
// so sibling parts still resolve their own slots. Return content only
|
|
434
|
+
// (leave note metadata untouched). Un-segmented: clear the one-shot
|
|
435
|
+
// stub as before (byte-unchanged).
|
|
436
|
+
if (segmentIndex !== undefined) return { content: body };
|
|
395
437
|
const { transcribe_stub: _drop, ...restMeta } = noteMeta;
|
|
396
438
|
return { content: body, metadata: restMeta };
|
|
397
439
|
},
|
|
@@ -449,6 +491,12 @@ export function startTranscriptionWorker(opts: TranscriptionWorkerOpts): Transcr
|
|
|
449
491
|
// vs. the legacy stub-patching path (Lens flow). Auto-write notes also
|
|
450
492
|
// surface failures so the user can retry from the transcript note.
|
|
451
493
|
const isAutoOrigin = meta.transcribe_origin === "auto";
|
|
494
|
+
// Voice W2: when this attachment is one segment of a longer recording
|
|
495
|
+
// (client-set `segment_index`), the legacy in-body path targets this
|
|
496
|
+
// part's markers instead of the bare ones. Undefined for un-segmented
|
|
497
|
+
// attachments — byte-unchanged behavior. Only the legacy path consults it;
|
|
498
|
+
// the auto/transcript-note path is untouched (segments are a memo concern).
|
|
499
|
+
const segmentIndex = segmentIndexOf(meta);
|
|
452
500
|
|
|
453
501
|
// Honor backoff — we re-check here in case another tick queued this
|
|
454
502
|
// attachment between the listing and now.
|
|
@@ -469,7 +517,7 @@ export function startTranscriptionWorker(opts: TranscriptionWorkerOpts): Transcr
|
|
|
469
517
|
if (isAutoOrigin) {
|
|
470
518
|
await writeFailureTranscriptNote(store, attachment, "audio file not found", undefined, undefined);
|
|
471
519
|
} else {
|
|
472
|
-
await applyFailureMarker(store, attachment.noteId);
|
|
520
|
+
await applyFailureMarker(store, attachment.noteId, segmentIndex);
|
|
473
521
|
}
|
|
474
522
|
return;
|
|
475
523
|
}
|
|
@@ -527,7 +575,7 @@ export function startTranscriptionWorker(opts: TranscriptionWorkerOpts): Transcr
|
|
|
527
575
|
if (isAutoOrigin) {
|
|
528
576
|
await writeFailureTranscriptNote(store, attachment, errMsg, apiErr?.code, undefined);
|
|
529
577
|
} else {
|
|
530
|
-
await applyFailureMarker(store, attachment.noteId);
|
|
578
|
+
await applyFailureMarker(store, attachment.noteId, segmentIndex);
|
|
531
579
|
}
|
|
532
580
|
// retention=never drops the audio on any terminal state, including
|
|
533
581
|
// failure. The user opted in to "I don't want the audio kept around
|
|
@@ -578,6 +626,16 @@ export function startTranscriptionWorker(opts: TranscriptionWorkerOpts): Transcr
|
|
|
578
626
|
// before the transcript arrives opts out of the overwrite. OC-guarded
|
|
579
627
|
// (vault#435): re-applied against fresh content on a conflict so a
|
|
580
628
|
// concurrent user edit isn't clobbered.
|
|
629
|
+
//
|
|
630
|
+
// Success replaces whichever of THIS part's markers is present (bare, or
|
|
631
|
+
// `(part N)` when segmented). Built with no `/g` flag so `.replace`
|
|
632
|
+
// swaps only the FIRST match — a canonical capture body holds exactly
|
|
633
|
+
// one marker per part; alternation preserves positional-first semantics
|
|
634
|
+
// (a retried success replaces the failure marker where a first-try
|
|
635
|
+
// success replaced the pending one) so byte-for-byte matching today's
|
|
636
|
+
// un-segmented behavior.
|
|
637
|
+
const { pending, unavailable } = markersFor(segmentIndex);
|
|
638
|
+
const successTarget = new RegExp(`${escapeRegExp(pending)}|${escapeRegExp(unavailable)}`);
|
|
581
639
|
await applyNoteTransformWithOC(
|
|
582
640
|
store,
|
|
583
641
|
attachment.noteId,
|
|
@@ -586,28 +644,31 @@ export function startTranscriptionWorker(opts: TranscriptionWorkerOpts): Transcr
|
|
|
586
644
|
const noteMeta = (note.metadata as Record<string, unknown> | undefined) ?? {};
|
|
587
645
|
if (noteMeta.transcribe_stub !== true) return null;
|
|
588
646
|
// Body policy (finding F — never destroy content):
|
|
589
|
-
// -
|
|
590
|
-
//
|
|
591
|
-
// unavailable._` marker, landing exactly where a first-try
|
|
592
|
-
// success would). The embed + surrounding capture body survive.
|
|
647
|
+
// - pending OR failure marker present → surgical replace in place.
|
|
648
|
+
// The embed + surrounding capture body survive.
|
|
593
649
|
// - neither present (user edited the note while pending) → APPEND
|
|
594
650
|
// the transcript instead of full-replacing the body, so the
|
|
595
651
|
// user's edits + the `![[memo]]` embed are preserved. The old
|
|
596
652
|
// code full-replaced here, which destroyed both.
|
|
597
653
|
let body: string;
|
|
598
|
-
if (
|
|
654
|
+
if (successTarget.test(note.content)) {
|
|
599
655
|
// Function replacer, NOT a string — speech-to-text is arbitrary
|
|
600
656
|
// user content, and String.replace treats `$&`, `$\``, `$'`,
|
|
601
657
|
// `$1`-`$9` as special patterns in a string replacement. A
|
|
602
658
|
// transcript containing `$&` would otherwise inject the matched
|
|
603
659
|
// marker text into the body. `() => transcript` returns the text
|
|
604
660
|
// verbatim.
|
|
605
|
-
body = note.content.replace(
|
|
661
|
+
body = note.content.replace(successTarget, () => transcript);
|
|
606
662
|
} else {
|
|
607
663
|
body = note.content.length > 0
|
|
608
664
|
? `${note.content}\n\n${transcript}`
|
|
609
665
|
: transcript;
|
|
610
666
|
}
|
|
667
|
+
// Segmented: the stub is SHARED across this note's parts — keep it
|
|
668
|
+
// set so sibling parts still resolve their own slots. Return content
|
|
669
|
+
// only (leave note metadata untouched). Un-segmented: clear the
|
|
670
|
+
// one-shot stub as before (byte-unchanged).
|
|
671
|
+
if (segmentIndex !== undefined) return { content: body };
|
|
611
672
|
const { transcribe_stub: _drop, ...restMeta } = noteMeta;
|
|
612
673
|
return { content: body, metadata: restMeta };
|
|
613
674
|
},
|