Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
509895a
Respect recording Trash and comment settings
3mdistal Oct 22, 2026
b78d1fd
Apply recording policies to comment reads
3mdistal Oct 22, 2026
e845de2
Apply Trash access to comment cleanup
3mdistal Oct 22, 2026
5d8350c
Apply comment visibility settings to recording history
3mdistal Oct 23, 2026
71fb7a9
Show unavailable state for missing shared recordings
3mdistal Oct 25, 2026
bda280f
Merge recording availability update into comment visibility
3mdistal Oct 25, 2026
4669b0d
Keep unavailable recordings out of share metadata
3mdistal Oct 25, 2026
11b00e1
Merge share availability updates
3mdistal Oct 25, 2026
cf8b022
Explain replacement of share retry assertions
3mdistal Oct 25, 2026
277d7a3
Merge share assertion documentation
3mdistal Oct 25, 2026
fb8ecb6
Respect recording lifecycle in bug report context
3mdistal Oct 25, 2026
d6d3d5d
Merge bug report context lifecycle checks
3mdistal Oct 25, 2026
a937177
Keep recording reactions independent of comments
3mdistal Oct 25, 2026
93bd875
Merge main and preserve Clips comment access checks
3mdistal Oct 26, 2026
1094282
Merge updated Clips access branch into comment visibility
3mdistal Oct 26, 2026
e781dc1
Apply recording Trash policy to activity recipients
3mdistal Oct 27, 2026
687fbd0
Bring activity recipient policy into comment visibility
3mdistal Oct 27, 2026
60c0241
Narrow recording context rows before lifecycle checks
3mdistal Oct 27, 2026
75288b2
Merge branch 't3/clips-trash-comment-install-fixes' into t3/clips-com…
3mdistal Oct 27, 2026
603d862
Preserve comment cleanup and recording search availability
3mdistal Oct 27, 2026
a089bd1
Read optional recording rows consistently across compiler checks
3mdistal Oct 27, 2026
1cde6b6
Merge branch 't3/clips-trash-comment-install-fixes' into t3/clips-com…
3mdistal Oct 27, 2026
9aafd80
Format the recording search changelog entry
3mdistal Oct 27, 2026
72f565a
Align recording expiry checks across shared access
3mdistal Oct 27, 2026
08334b2
Match expiry timestamp precision across recording readers
3mdistal Oct 27, 2026
ad49705
Share expiry access rules with agent reads and Slack unfurls
3mdistal Oct 27, 2026
9219a38
Apply recording expiry across Clips readers
3mdistal Oct 27, 2026
e4d86fe
Check recording expiry when content completes
3mdistal Oct 27, 2026
9564be1
Enforce recording expiry at mutation boundaries
3mdistal Oct 27, 2026
26725c3
Revalidate recording expiry before delayed writes
3mdistal Oct 27, 2026
c2ff6bb
Preserve expiry checks through queued and locked writes
3mdistal Oct 27, 2026
80a7a02
Honor linked recording permissions in meeting reads
3mdistal Oct 27, 2026
2932829
Remove unused meeting visibility test import
3mdistal Oct 27, 2026
6925321
Merge main after recording Trash landing
3mdistal Oct 27, 2026
42202d5
Recheck meeting editor access after finalization work
3mdistal Oct 27, 2026
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: 4 additions & 1 deletion templates/clips/actions/add-recording-to-space.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { and, eq } from "drizzle-orm";
import { z } from "zod";

import { getDb, schema } from "../server/db/index.js";
import { assertRecordingNotExpired } from "../server/lib/recording-read-access.js";
import { parseSpaceIds, stringifySpaceIds } from "../server/lib/recordings.js";

const MAX_CAS_ATTEMPTS = 5;
Expand All @@ -21,7 +22,9 @@ export default defineAction({
.describe("Whether to add or remove the space"),
}),
run: async (args) => {
await assertAccess("recording", args.recordingId, "editor");
assertRecordingNotExpired(
await assertAccess("recording", args.recordingId, "editor"),
);

const db = getDb();

Expand Down
5 changes: 4 additions & 1 deletion templates/clips/actions/archive-recording.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { eq } from "drizzle-orm";
import { z } from "zod";

import { getDb, schema } from "../server/db/index.js";
import { assertRecordingNotExpired } from "../server/lib/recording-read-access.js";

export default defineAction({
description:
Expand All @@ -13,7 +14,9 @@ export default defineAction({
id: z.string().describe("Recording ID"),
}),
run: async (args) => {
await assertAccess("recording", args.id, "editor");
assertRecordingNotExpired(
await assertAccess("recording", args.id, "editor"),
);

const db = getDb();

Expand Down
45 changes: 42 additions & 3 deletions templates/clips/actions/burn-recording-redactions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ const recording = vi.hoisted(() => ({
id: "rec_1",
title: "Test recording",
kind: "video" as string,
expiresAt: null as string | null,
ownerEmail: "owner@example.com",
storageMode: null as "own" | "hosted" | null,
videoUrl: "https://cdn.example.com/media/clips/rec_1.webm",
Expand Down Expand Up @@ -53,6 +54,8 @@ const state = vi.hoisted(() => ({
canStart: true,
filmstripCalls: [] as Array<Record<string, unknown>>,
duringEncode: null as null | (() => void),
duringUpload: null as null | (() => void),
accessRole: "editor",
}));

vi.mock("@agent-native/core/action", () => ({
Expand Down Expand Up @@ -102,13 +105,19 @@ vi.mock("../server/lib/upload-recording-media.js", () => ({
uploadRecordingMedia: vi.fn(async (input: Record<string, unknown>) => {
state.uploadCalls.push(input);
if (state.uploadError) throw state.uploadError;
state.duringUpload?.();
return state.uploadUrl ? { url: state.uploadUrl, provider: "s3" } : null;
}),
}));
vi.mock("@agent-native/core/sharing", () => ({
assertAccess: vi.fn(async () => undefined),
vi.mock("@agent-native/core/sharing", async (original) => ({
...(await original<typeof import("@agent-native/core/sharing")>()),
assertAccess: vi.fn(async () => ({
role: state.accessRole,
resource: { expiresAt: recording.expiresAt },
})),
}));
vi.mock("drizzle-orm", () => ({
vi.mock("drizzle-orm", async (original) => ({
...(await original<typeof import("drizzle-orm")>()),
and: (...args: unknown[]) => args,
eq: (column: unknown, value: unknown) => ({ column, value }),
}));
Expand Down Expand Up @@ -224,11 +233,41 @@ describe("burning redactions into a recording", () => {
state.uploadError = null;
state.canStart = true;
state.duringEncode = null;
state.duringUpload = null;
state.accessRole = "editor";
recording.expiresAt = null;
recording.videoUrl = "https://cdn.example.com/media/clips/rec_1.webm";
recording.kind = "video";
recording.storageMode = null;
});

it.each(["encode", "upload"])(
"refuses expiry during %s before replacing media",
async (phase) => {
const expire = () => {
recording.expiresAt = "2000-01-01T00:00:00Z";
};
if (phase === "encode") state.duringEncode = expire;
else state.duringUpload = expire;
await expect(run()).rejects.toMatchObject({ statusCode: 403 });
expect(state.updated).toHaveLength(0);
expect(state.deleted).not.toContain(
"https://cdn.example.com/media/clips/rec_1.webm",
);
expect(state.deleted).toEqual(
phase === "upload" ? [state.uploadUrl] : [],
);
},
);
it("retains owner completion when expiry changes during encoding", async () => {
state.accessRole = "owner";
state.duringEncode = () => {
recording.expiresAt = "2000-01-01T00:00:00Z";
};
await expect(run()).resolves.toMatchObject({ id: "rec_1" });
expect(state.updated).toHaveLength(2);
});

it("returns a typed setup error when storage is not connected", async () => {
state.uploadUrl = null;

Expand Down
50 changes: 35 additions & 15 deletions templates/clips/actions/burn-recording-redactions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { getDb, schema } from "../server/db/index.js";
import { ensureRecordingThumbnail } from "../server/lib/ensure-recording-thumbnail.js";
import { loadRecordingMediaBytes } from "../server/lib/public-agent-context.js";
import { deleteStoredMediaUrl } from "../server/lib/recording-media-cleanup.js";
import { assertRecordingNotExpired } from "../server/lib/recording-read-access.js";
import { getCurrentOwnerEmail } from "../server/lib/recordings.js";
import {
failBurn,
Expand Down Expand Up @@ -86,7 +87,9 @@ export async function burnRedactionsFor(args: {
recordingId: string;
ownerEmail: string;
}) {
await assertAccess("recording", args.recordingId, "editor");
assertRecordingNotExpired(
Comment thread
3mdistal marked this conversation as resolved.
await assertAccess("recording", args.recordingId, "editor"),
);

const db = getDb();
const { ownerEmail } = args;
Expand Down Expand Up @@ -225,6 +228,9 @@ export async function burnRedactionsFor(args: {

timer.mark("verify");
setBurnProgress(args.recordingId, 88);
assertRecordingNotExpired(
await assertAccess("recording", args.recordingId, "editor"),
);
const upload = await uploadRecordingMedia({
recordingId: args.recordingId,
data: burned,
Expand All @@ -238,6 +244,21 @@ export async function burnRedactionsFor(args: {
failStorageSetupRequired();
}

const discardUpload = async () => {
if (upload.url !== previousVideoUrl) {
try {
await deleteStoredMediaUrl(upload.url, {
storageMode: existing.storageMode,
recordingId: existing.id,
});
} catch (err) {
console.warn("[burn] could not delete the orphaned upload", {
url: upload.url,
err: err instanceof Error ? err.message : String(err),
});
}
}
};
timer.mark("upload");
const burnedAt = new Date().toISOString();

Expand Down Expand Up @@ -286,6 +307,15 @@ export async function burnRedactionsFor(args: {
],
};

try {
assertRecordingNotExpired(
await assertAccess("recording", args.recordingId, "editor"),
);
} catch (error) {
await discardUpload();
throw error;
}

const updated = await db
.update(schema.recordings)
.set({
Expand Down Expand Up @@ -318,19 +348,7 @@ export async function burnRedactionsFor(args: {
.returning({ id: schema.recordings.id });

if (!updated.length) {
if (upload.url !== previousVideoUrl) {
try {
await deleteStoredMediaUrl(upload.url, {
storageMode: existing.storageMode,
recordingId: existing.id,
});
} catch (err) {
console.warn("[burn] could not delete the orphaned upload", {
url: upload.url,
err: err instanceof Error ? err.message : String(err),
});
}
}
await discardUpload();
throw new Error(
"The recording changed while the redaction was rendering. Nothing was deleted — try again.",
);
Expand Down Expand Up @@ -451,7 +469,9 @@ export default defineAction({
recordingId: z.string().describe("Recording ID"),
}),
run: async (args) => {
await assertAccess("recording", args.recordingId, "editor");
assertRecordingNotExpired(
await assertAccess("recording", args.recordingId, "editor"),
);
const ownerEmail = getCurrentOwnerEmail();

const db = getDb();
Expand Down
5 changes: 4 additions & 1 deletion templates/clips/actions/clear-edits.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,10 @@ vi.mock("@agent-native/core/application-state", () => ({
writeAppState: vi.fn(),
}));
vi.mock("@agent-native/core/sharing", () => ({
assertAccess: vi.fn(),
assertAccess: vi.fn(async () => ({
role: "editor",
resource: { expiresAt: null },
})),
}));
vi.mock("drizzle-orm", () => ({
eq: (column: string, value: unknown) => ({ column, value }),
Expand Down
5 changes: 4 additions & 1 deletion templates/clips/actions/clear-edits.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
serializeEdits,
} from "../app/lib/timestamp-mapping.js";
import { getDb, schema } from "../server/db/index.js";
import { assertRecordingNotExpired } from "../server/lib/recording-read-access.js";
import { assertNativeRecordingMedia } from "./lib/native-media.js";

const MAX_CAS_ATTEMPTS = 5;
Expand All @@ -21,7 +22,9 @@ export default defineAction({
recordingId: z.string().describe("Recording ID"),
}),
run: async (args) => {
await assertAccess("recording", args.recordingId, "editor");
assertRecordingNotExpired(
await assertAccess("recording", args.recordingId, "editor"),
);

const db = getDb();

Expand Down
45 changes: 43 additions & 2 deletions templates/clips/actions/complete-workflow.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import { beforeEach, describe, expect, it, vi } from "vitest";

const mocks = vi.hoisted(() => ({
assertAccess: vi.fn(async () => undefined),
assertAccess: vi.fn(async () => ({
role: "viewer",
resource: { expiresAt: null as string | null },
})),
compareAndSetAppState: vi.fn(async () => true),
readAppState: vi.fn(),
}));
Expand All @@ -17,7 +20,8 @@ vi.mock("@agent-native/core/application-state", () => ({
mocks.compareAndSetAppState(...args),
readAppState: (...args: unknown[]) => mocks.readAppState(...args),
}));
vi.mock("@agent-native/core/sharing", () => ({
vi.mock("@agent-native/core/sharing", async (original) => ({
...(await original<typeof import("@agent-native/core/sharing")>()),
assertAccess: (...args: unknown[]) => mocks.assertAccess(...args),
}));

Expand All @@ -36,6 +40,10 @@ const generating = {

beforeEach(() => {
vi.clearAllMocks();
mocks.assertAccess.mockResolvedValue({
role: "viewer",
resource: { expiresAt: null },
});
mocks.compareAndSetAppState.mockResolvedValue(true);
});

Expand Down Expand Up @@ -132,3 +140,36 @@ describe("complete-workflow", () => {
).resolves.toEqual({ saved: true, recordingId: "rec_1", requestedAt });
});
});

it.each(["2000-01-01T00:00:00Z", "not-a-date"])(
"refuses workflow completion after recording availability ends (%s)",
async (expiresAt) => {
mocks.assertAccess.mockResolvedValue({
role: "viewer",
resource: { expiresAt },
});
mocks.readAppState
.mockResolvedValueOnce(generating)
.mockResolvedValueOnce({ ...generating, status: "ready", content });
await expect(
action.run({ recordingId: "rec_1", requestedAt, requestId, content }),
).rejects.toMatchObject({ statusCode: 403 });
expect(mocks.compareAndSetAppState).not.toHaveBeenCalled();
},
);
it.each(["2000-01-01T00:00:00Z", "not-a-date"])(
"allows the owner to complete an unavailable recording workflow (%s)",
async (expiresAt) => {
mocks.assertAccess.mockResolvedValue({
role: "owner",
resource: { expiresAt },
});
mocks.readAppState
.mockResolvedValueOnce(generating)
.mockResolvedValueOnce({ ...generating, status: "ready", content });
await expect(
action.run({ recordingId: "rec_1", requestedAt, requestId, content }),
).resolves.toMatchObject({ saved: true });
expect(mocks.compareAndSetAppState).toHaveBeenCalledOnce();
},
);
5 changes: 4 additions & 1 deletion templates/clips/actions/complete-workflow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
import { assertAccess } from "@agent-native/core/sharing";
import { z } from "zod";

import { assertRecordingNotExpired } from "../server/lib/recording-read-access.js";
import {
matchesWorkflowRequest,
WorkflowKindSchema,
Expand Down Expand Up @@ -45,7 +46,9 @@ export default defineAction({
.describe("Final workflow document in markdown"),
}),
run: async ({ recordingId, requestedAt, requestId, content }) => {
await assertAccess("recording", recordingId, "viewer");
assertRecordingNotExpired(
await assertAccess("recording", recordingId, "viewer"),
);

const stateKey = `clips-workflow-${recordingId}`;
const current = await readAppState(stateKey);
Expand Down
5 changes: 4 additions & 1 deletion templates/clips/actions/create-cta.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,10 @@ function selectBuilder(result: unknown[]) {

beforeEach(() => {
vi.clearAllMocks();
mockAssertAccess.mockResolvedValue(undefined);
mockAssertAccess.mockResolvedValue({
role: "editor",
resource: { expiresAt: null },
});
mockWriteAppState.mockResolvedValue(undefined);
mockNanoid.mockReturnValue("cta_1");
mockDb.select.mockReturnValue(
Expand Down
5 changes: 4 additions & 1 deletion templates/clips/actions/create-cta.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { eq } from "drizzle-orm";
import { z } from "zod";

import { getDb, schema } from "../server/db/index.js";
import { assertRecordingNotExpired } from "../server/lib/recording-read-access.js";
import { nanoid } from "../server/lib/recordings.js";
import { ctaUrlSchema } from "./lib/cta-url.js";

Expand All @@ -22,7 +23,9 @@ export default defineAction({
}),
run: async (args) => {
const db = getDb();
await assertAccess("recording", args.recordingId, "editor");
assertRecordingNotExpired(
await assertAccess("recording", args.recordingId, "editor"),
);

const [recording] = await db
.select({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,15 @@ import { eq } from "drizzle-orm";
import { z } from "zod";

import { getDb, schema } from "../server/db/index.js";
import { assertRecordingNotExpired } from "../server/lib/recording-read-access.js";
import deleteRecordingPermanent from "./delete-recording-permanent.js";

export default defineAction({
description:
"Delete an expired agent-created Clip only if it is still private and has not been renamed, shared, commented on, reacted to, tagged, archived, or trashed.",
schema: z.object({ id: z.string() }),
run: async ({ id }) => {
await assertAccess("recording", id, "editor");
assertRecordingNotExpired(await assertAccess("recording", id, "editor"));
const db = getDb();
const [recording] = await db
.select()
Expand Down
Loading
Loading