Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/content-clips-peer-secret.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@agent-native/core": patch
---

Add the `clips.contentPeerSecret` server config field (`CONTENT_CLIPS_PEER_SECRET`) and `resolveContentClipsPeerSecret()`: a secret only the Content and Clips deployments hold, for Content's server-to-server requests about Clips in its documents. The resolver returns null while it is unset and throws `AppConfigurationError` when it is shorter than 32 characters or equal to `A2A_SECRET`. `signShortLivedToken` and `verifyShortLivedToken` take an optional `{ signingKey }` that replaces the app's server secret, with no fallback, so a token can be bound to one key.
5 changes: 5 additions & 0 deletions .changeset/host-access-signed-out.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@agent-native/core": patch
---

`registerHostAccessResolver` takes an `{ includeSignedOut: true }` option for a host that vouches for signed-out readers of one resource. Only a resolver registered with it is asked about signed-out requests, with `ctx.userEmail` unset; every other resolver is still asked only about signed-in requests, as before.
1 change: 1 addition & 0 deletions docs/environment-variables.md
Original file line number Diff line number Diff line change
Expand Up @@ -579,6 +579,7 @@ only in code.
| `clips.hostedStorage.ipPrefixDailyGrantCap` | `CLIPS_HOSTED_STORAGE_IP_PREFIX_DAILY_GRANT_CAP` | number | — | Optional maximum number of hosted Clips recording grants per IP prefix per day. |
| `clips.hostedStorage.globalRetainedByteCap` | `CLIPS_HOSTED_STORAGE_GLOBAL_RETAINED_BYTE_CAP` | number | — | Optional maximum total bytes retained in hosted Clips object storage. |
| `clips.hostedStorage.retainedByteAlertRatio` | `CLIPS_HOSTED_STORAGE_RETAINED_BYTE_ALERT_RATIO` | number | `0.8` | Fraction of the global retained-byte cap at which hosted Clips storage should alert. |
| `clips.contentPeerSecret` | `CONTENT_CLIPS_PEER_SECRET` | string | — | Secret only the Content and Clips deployments hold. Content signs its server-to-server requests about Clips in its documents with it, and Clips accepts no other credential on those routes. Must differ from A2A_SECRET. |
| `dispatch.adminEmails` | `DISPATCH_ADMIN_EMAILS` | array | `[]` | Comma-separated deployment administrators for Dispatch. |
| `dispatch.workspaceOwnerEmails` | `WORKSPACE_OWNER_EMAIL` | array | `[]` | Comma-separated workspace owner emails trusted by Dispatch. |
| `dispatch.defaultOwnerEmail` | `DISPATCH_DEFAULT_OWNER_EMAIL` | string | — | Default Dispatch owner email. |
Expand Down
72 changes: 71 additions & 1 deletion packages/core/src/app-config/clips.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";

import { resolveClipsHostedStorageConfig } from "./clips.js";
import {
resolveClipsHostedStorageConfig,
resolveContentClipsPeerSecret,
} from "./clips.js";
import { AppConfigurationError } from "./configuration-error.js";
import {
defineAppConfig,
Expand Down Expand Up @@ -148,3 +151,70 @@ describe("Clips hosted storage config", () => {
expect(() => getAppConfig()).toThrow();
});
});

describe("Content-to-Clips peer secret", () => {
const PEER_SECRET = "content-clips-peer-secret-0123456789abcdef";
const DEPLOYMENT_SECRET = "deployment-a2a-secret-0123456789abcdef";

beforeEach(() => {
resetAppConfigForTests();
process.env = { ...originalEnv };
delete process.env.CONTENT_CLIPS_PEER_SECRET;
});

afterEach(() => {
resetAppConfigForTests();
process.env = { ...originalEnv };
});

it("reads the secret from its deployment alias", () => {
process.env.CONTENT_CLIPS_PEER_SECRET = PEER_SECRET;

expect(
resolveContentClipsPeerSecret(getAppConfig().clips, DEPLOYMENT_SECRET),
).toBe(PEER_SECRET);
});

it.each([
["unset", undefined],
["empty", ""],
["whitespace", " "],
])("is absent, not an error, while %s", (_name, value) => {
if (value !== undefined) process.env.CONTENT_CLIPS_PEER_SECRET = value;

expect(
resolveContentClipsPeerSecret(getAppConfig().clips, DEPLOYMENT_SECRET),
).toBe(null);
});

it("is absent, not an error, when app config sets it empty", () => {
defineAppConfig({ clips: { contentPeerSecret: "" } });

expect(
resolveContentClipsPeerSecret(getAppConfig().clips, DEPLOYMENT_SECRET),
).toBe(null);
});

it("refuses the deployment A2A secret, which every app holds", () => {
process.env.CONTENT_CLIPS_PEER_SECRET = DEPLOYMENT_SECRET;

expect(() =>
resolveContentClipsPeerSecret(
getAppConfig().clips,
` ${DEPLOYMENT_SECRET} `,
),
).toThrow(
new AppConfigurationError(
"CONTENT_CLIPS_PEER_SECRET must differ from A2A_SECRET.",
),
);
});

it("refuses a secret too short to sign with", () => {
process.env.CONTENT_CLIPS_PEER_SECRET = "short-peer-secret";

expect(() =>
resolveContentClipsPeerSecret(getAppConfig().clips, undefined),
).toThrow(AppConfigurationError);
});
});
34 changes: 34 additions & 0 deletions packages/core/src/app-config/clips.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,12 +69,46 @@ export const clipsHostedStorageConfig = z.object({

export const clipsConfig = z.object({
hostedStorage: clipsHostedStorageConfig.prefault({}),
contentPeerSecret: z.string().optional().meta({
env: "CONTENT_CLIPS_PEER_SECRET",
doc: "Secret only the Content and Clips deployments hold. Content signs its server-to-server requests about Clips in its documents with it, and Clips accepts no other credential on those routes. Must differ from A2A_SECRET.",
}),
});

export type ClipsConfig = z.output<typeof clipsConfig>;

export type ClipsHostedStorageConfig = z.output<
typeof clipsHostedStorageConfig
>;

const MIN_CONTENT_CLIPS_PEER_SECRET_CHARS = 32;

/**
* The secret Content and Clips sign and verify Content's Clip requests with,
* or null when it is not set. Read it through this on both sides: a value
* equal to the deployment `A2A_SECRET` is one every app holds, so it would
* not show that a request came from Content, and it is refused rather than
* used.
*/
export function resolveContentClipsPeerSecret(
config: ClipsConfig,
deploymentA2ASecret: string | undefined,
): string | null {
const secret = config.contentPeerSecret?.trim();
if (!secret) return null;
if (secret.length < MIN_CONTENT_CLIPS_PEER_SECRET_CHARS) {
throw new AppConfigurationError(
`CONTENT_CLIPS_PEER_SECRET must be at least ${MIN_CONTENT_CLIPS_PEER_SECRET_CHARS} characters.`,
);
}
if (secret === deploymentA2ASecret?.trim()) {
throw new AppConfigurationError(
"CONTENT_CLIPS_PEER_SECRET must differ from A2A_SECRET.",
);
}
return secret;
}

export type ResolvedClipsHostedStorageConfig = ClipsHostedStorageConfig & {
enabled: true;
endpoint: string;
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/app-config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ export { resolveAppHomePath } from "./app-identity.js";
export { AppConfigurationError } from "./configuration-error.js";
export {
resolveClipsHostedStorageConfig,
resolveContentClipsPeerSecret,
type ClipsConfig,
type ClipsHostedStorageConfig,
type ResolvedClipsHostedStorageConfig,
} from "./clips.js";
Expand Down
3 changes: 3 additions & 0 deletions packages/core/src/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,11 @@ export {
resolveAppHomePath,
resetAppConfigForTests,
resolveClipsHostedStorageConfig,
resolveContentClipsPeerSecret,
appConfigSchema,
type AppConfig,
type AppConfigInput,
type ClipsConfig,
type ClipsHostedStorageConfig,
type ResolvedClipsHostedStorageConfig,
} from "../app-config/index.js";
Expand Down Expand Up @@ -931,6 +933,7 @@ export {
signShortLivedToken,
verifyShortLivedToken,
type ShortLivedTokenClaims,
type ShortLivedTokenKeyOptions,
type VerifyResult as ShortLivedTokenVerifyResult,
} from "./short-lived-token.js";
export {
Expand Down
31 changes: 31 additions & 0 deletions packages/core/src/server/short-lived-token.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,37 @@ describe("short-lived-token", () => {
expect(result.ok).toBe(true);
});

it("keeps a token signed with an explicit key apart from the server secret", () => {
const signingKey = "explicit-short-lived-key-0123456789abcdef";
const explicit = signShortLivedToken(
{ resourceId: "rec_abc" },
{ signingKey },
);
const server = signShortLivedToken({ resourceId: "rec_abc" });

expect(verifyShortLivedToken(explicit, "rec_abc", { signingKey }).ok).toBe(
true,
);
expect(verifyShortLivedToken(explicit, "rec_abc")).toEqual({
ok: false,
reason: "bad_signature",
});
expect(verifyShortLivedToken(server, "rec_abc", { signingKey })).toEqual({
ok: false,
reason: "bad_signature",
});
});

it("refuses an empty explicit key instead of using the server secret", () => {
const token = signShortLivedToken({ resourceId: "rec_abc" });
expect(() =>
signShortLivedToken({ resourceId: "rec_abc" }, { signingKey: "" }),
).toThrow("An explicit short-lived token signing key is empty.");
expect(() =>
verifyShortLivedToken(token, "rec_abc", { signingKey: "" }),
).toThrow("An explicit short-lived token signing key is empty.");
});

it("includes viewerEmail in claims when supplied", () => {
const token = signShortLivedToken({
resourceId: "rec_abc",
Expand Down
27 changes: 24 additions & 3 deletions packages/core/src/server/short-lived-token.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,15 @@ interface DecodedClaims {
exp: number;
}

export interface ShortLivedTokenKeyOptions {
/**
* Sign or verify with this key alone, never the app's server secret, so a
* token minted under another key (one derived from `A2A_SECRET`, say)
* cannot pass. An empty key throws rather than fall back.
*/
signingKey?: string;
}

export type VerifyResult =
| { ok: true; viewerEmail?: string; agentLabel?: string }
| { ok: false; reason: string };
Expand All @@ -50,6 +59,14 @@ function getSigningKey(): string {
return _devSigningKey;
}

function keyFor(options: ShortLivedTokenKeyOptions | undefined): string {
if (options?.signingKey === undefined) return getSigningKey();
if (!options.signingKey) {
throw new Error("An explicit short-lived token signing key is empty.");
}
return options.signingKey;
}

function base64UrlEncode(buf: Buffer | string): string {
const b = typeof buf === "string" ? Buffer.from(buf, "utf8") : buf;
return b
Expand All @@ -64,7 +81,10 @@ function base64UrlDecode(s: string): Buffer {
return Buffer.from(padded.replace(/-/g, "+").replace(/_/g, "/"), "base64");
}

export function signShortLivedToken(claims: ShortLivedTokenClaims): string {
export function signShortLivedToken(
claims: ShortLivedTokenClaims,
options?: ShortLivedTokenKeyOptions,
): string {
const ttl = claims.ttlSeconds ?? DEFAULT_TTL_SECONDS;
const payload: DecodedClaims = {
resourceId: claims.resourceId,
Expand All @@ -75,7 +95,7 @@ export function signShortLivedToken(claims: ShortLivedTokenClaims): string {

const payloadStr = base64UrlEncode(JSON.stringify(payload));
const sig = base64UrlEncode(
crypto.createHmac("sha256", getSigningKey()).update(payloadStr).digest(),
crypto.createHmac("sha256", keyFor(options)).update(payloadStr).digest(),
);
return `${payloadStr}.${sig}`;
}
Expand All @@ -95,6 +115,7 @@ export function signShortLivedToken(claims: ShortLivedTokenClaims): string {
export function verifyShortLivedToken(
token: string,
expectedResourceId: string,
options?: ShortLivedTokenKeyOptions,
): VerifyResult {
if (typeof token !== "string" || !token.includes(".")) {
return { ok: false, reason: "malformed" };
Expand All @@ -103,7 +124,7 @@ export function verifyShortLivedToken(
if (!payloadStr || !sig) return { ok: false, reason: "malformed" };

const expected = base64UrlEncode(
crypto.createHmac("sha256", getSigningKey()).update(payloadStr).digest(),
crypto.createHmac("sha256", keyFor(options)).update(payloadStr).digest(),
);

const sigBuf = Buffer.from(sig, "utf8");
Expand Down
52 changes: 52 additions & 0 deletions packages/core/src/sharing/access.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -658,6 +658,58 @@ describe("shareable resource access helpers", () => {
}
});

it("never asks a host about signed-out requests unless it opted in", async () => {
await insertDoc({ id: "doc-hosted-signed-in-only" });
const asked: Array<string | undefined> = [];
registerHostAccessResolver(resourceType, async (_resource, ctx) => {
asked.push(ctx.userEmail);
return "editor";
});
try {
await runWithRequestContext({}, async () => {
await expect(
resolveAccess(resourceType, "doc-hosted-signed-in-only"),
).resolves.toBeNull();
});
expect(asked).toEqual([]);

await runWithRequestContext({ userEmail: viewerEmail }, async () => {
await expect(
resolveAccess(resourceType, "doc-hosted-signed-in-only"),
).resolves.toMatchObject({ role: "editor" });
});
expect(asked).toEqual([viewerEmail]);
} finally {
registerHostAccessResolver(resourceType, null);
}
});

it("asks a host that opted in about signed-out requests", async () => {
await insertDoc({ id: "doc-hosted-anonymous" });
const asked: Array<string | undefined> = [];
registerHostAccessResolver(
resourceType,
async (resource, ctx) => {
asked.push(ctx.userEmail);
return resource.id === "doc-hosted-anonymous" ? "viewer" : undefined;
},
{ includeSignedOut: true },
);
try {
await runWithRequestContext({}, async () => {
await expect(
resolveAccess(resourceType, "doc-hosted-anonymous"),
).resolves.toMatchObject({ role: "viewer" });
await expect(
assertAccess(resourceType, "doc-hosted-anonymous", "editor"),
).rejects.toThrow();
});
expect(asked).toContain(undefined);
} finally {
registerHostAccessResolver(resourceType, null);
}
});

it("resolves read and write roles without letting visibility imply edit access", async () => {
await insertDoc({ id: "doc-private" });
await insertDoc({ id: "doc-owned-other-org", orgId: otherOrgId });
Expand Down
Loading
Loading