diff --git a/apps/discord-gateway/src/mediaControlRouting.test.ts b/apps/discord-gateway/src/mediaControlRouting.test.ts index 43bd055..53a16aa 100644 --- a/apps/discord-gateway/src/mediaControlRouting.test.ts +++ b/apps/discord-gateway/src/mediaControlRouting.test.ts @@ -12,11 +12,11 @@ describe("Discord media control routing", () => { it("maps Discord control actions to the shared event media command model", () => { assert.equal(eventMediaCommandTypeForDiscordAction("start"), "start_program"); assert.equal(eventMediaCommandTypeForDiscordAction("stop"), "stop_program"); - assert.equal(eventMediaCommandTypeForDiscordAction("hold"), "switch_hold"); - assert.equal(eventMediaCommandTypeForDiscordAction("next"), "next_slot"); - assert.equal(eventMediaCommandTypeForDiscordAction("previous"), "previous_slot"); + assert.equal(eventMediaCommandTypeForDiscordAction("hold"), "hold_current"); + assert.equal(eventMediaCommandTypeForDiscordAction("next"), "switch_next"); + assert.equal(eventMediaCommandTypeForDiscordAction("previous"), "switch_previous"); assert.equal(eventMediaCommandTypeForDiscordAction("source"), "switch_source"); - assert.equal(eventMediaCommandTypeForDiscordAction("fallback"), "force_direct_link_fallback"); + assert.equal(eventMediaCommandTypeForDiscordAction("fallback"), "publish_fallback_link"); assert.equal(eventMediaCommandTypeForDiscordAction("publish_watch_link"), "publish_current_public_watch_link"); assert.equal(eventMediaCommandTypeForDiscordAction("refresh"), undefined); }); @@ -43,7 +43,7 @@ describe("Discord media control routing", () => { assert.equal(route.ack, "defer_message_update"); assert.equal(route.eventId, "event_123"); assert.equal(route.requiresConfirmation, false); - assert.deepEqual(route.command, { type: "switch_hold" }); + assert.deepEqual(route.command, { type: "hold_current" }); }); it("routes stale panel component clicks to ephemeral warnings without commands", () => { diff --git a/apps/discord-gateway/src/mediaControlRouting.ts b/apps/discord-gateway/src/mediaControlRouting.ts index 91cc1bc..454b7c5 100644 --- a/apps/discord-gateway/src/mediaControlRouting.ts +++ b/apps/discord-gateway/src/mediaControlRouting.ts @@ -57,11 +57,11 @@ type CommandAction = Exclude; const ACTION_TO_COMMAND_TYPE = { start: "start_program", stop: "stop_program", - hold: "switch_hold", - next: "next_slot", - previous: "previous_slot", + hold: "hold_current", + next: "switch_next", + previous: "switch_previous", source: "switch_source", - fallback: "force_direct_link_fallback", + fallback: "publish_fallback_link", publish_watch_link: "publish_current_public_watch_link", } as const satisfies Record; diff --git a/convex/_communityAuthority.ts b/convex/_communityAuthority.ts index 1a8a388..1f8e548 100644 --- a/convex/_communityAuthority.ts +++ b/convex/_communityAuthority.ts @@ -15,13 +15,28 @@ type IdentityLike = { name?: string; }; -type CommunityCapability = +export type CommunityCapability = + | "edit_community_profile" | "manage_profile" + | "manage_roster" | "manage_events" + | "manage_event_media" + | "view_event_operations" | "manage_staff" | "manage_integrations" | "manage_billing"; +const communityCapabilityAliases: Partial> = { + edit_community_profile: ["manage_profile"], + manage_profile: ["edit_community_profile"], +}; + +function hasCapability(capabilities: CommunityCapability[], requested: CommunityCapability): boolean { + const accepted = [requested, ...(communityCapabilityAliases[requested] ?? [])]; + + return accepted.some((capability) => capabilities.includes(capability)); +} + export function toAuthSubject(identity: IdentityLike): AuthSubject { return { tokenIdentifier: identity.tokenIdentifier, @@ -40,6 +55,15 @@ export async function subjectHasCommunityCapability( communityProfileId: Id<"profiles">, subject: AuthSubject, capability: CommunityCapability, +): Promise { + return subjectHasAnyCommunityCapability(db, communityProfileId, subject, [capability]); +} + +export async function subjectHasAnyCommunityCapability( + db: DatabaseReader, + communityProfileId: Id<"profiles">, + subject: AuthSubject, + capabilities: CommunityCapability[], ): Promise { const authorities = await db .query("communityAuthorities") @@ -49,7 +73,9 @@ export async function subjectHasCommunityCapability( .eq("state", "active") .eq("communityProfileId", communityProfileId), ) - .take(1); + .take(20); - return authorities.some((authority) => authority.capabilities.includes(capability)); + return authorities.some((authority) => + capabilities.some((capability) => hasCapability(authority.capabilities as CommunityCapability[], capability)), + ); } diff --git a/convex/_eventMediaControl.ts b/convex/_eventMediaControl.ts index 61600ea..bac7679 100644 --- a/convex/_eventMediaControl.ts +++ b/convex/_eventMediaControl.ts @@ -49,7 +49,7 @@ export type EventMediaSourcePurpose = | "fallback" | "watch"; -export type EventMediaSourceState = "draft" | "ready" | "live" | "offline" | "failed" | "disabled"; +export type EventMediaSourceState = "draft" | "ready" | "live" | "offline" | "stale" | "unknown" | "failed" | "disabled"; export type EventMediaSceneType = "source" | "hold_slate" | "intro" | "outro" | "offline_card" | "countdown"; @@ -68,7 +68,13 @@ export type EventMediaComplianceGateState = "pending" | "accepted" | "blocked"; export type EventMediaCommandType = | "start_program" | "stop_program" + | "preview_source" + | "switch_next" + | "switch_previous" | "switch_source" + | "hold_current" + | "show_hold_scene" + | "publish_fallback_link" | "switch_hold" | "next_slot" | "previous_slot" @@ -185,6 +191,7 @@ export type SanitizedVrcdnOperatorOwnedOutputSetup = { export type EventMediaCommandInput = { type: EventMediaCommandType; targetSourceKey?: string; + targetSceneKey?: string; targetOutputKey?: string; publicFallbackLinks?: EventMediaPublicLinkInput[]; note?: string; @@ -193,6 +200,7 @@ export type EventMediaCommandInput = { export type SanitizedEventMediaCommand = { type: EventMediaCommandType; targetSourceKey?: string; + targetSceneKey?: string; targetOutputKey?: string; publicFallbackLinks: EventMediaPublicLink[]; note?: string; @@ -262,6 +270,8 @@ export const eventMediaSourceStateValidator = v.union( v.literal("ready"), v.literal("live"), v.literal("offline"), + v.literal("stale"), + v.literal("unknown"), v.literal("failed"), v.literal("disabled"), ); @@ -312,7 +322,13 @@ export const eventMediaComplianceGateStateValidator = v.union( export const eventMediaCommandTypeValidator = v.union( v.literal("start_program"), v.literal("stop_program"), + v.literal("preview_source"), + v.literal("switch_next"), + v.literal("switch_previous"), v.literal("switch_source"), + v.literal("hold_current"), + v.literal("show_hold_scene"), + v.literal("publish_fallback_link"), v.literal("switch_hold"), v.literal("next_slot"), v.literal("previous_slot"), @@ -616,21 +632,30 @@ export function sanitizeEventMediaWorkerArtifactLinks( export function sanitizeEventMediaCommandInput(input: EventMediaCommandInput): SanitizedEventMediaCommand { const targetSourceKey = sanitizeControlKey(input.targetSourceKey, "Target source key"); + const targetSceneKey = sanitizeControlKey(input.targetSceneKey, "Target scene key"); const targetOutputKey = sanitizeControlKey(input.targetOutputKey, "Target output key"); const publicFallbackLinks = sanitizeEventMediaPublicLinks(input.publicFallbackLinks); const note = optionalBoundedText(input.note, "Media command note", CONTROL_NOTE_MAX_LENGTH); - if (["switch_source", "mark_source_live", "mark_source_offline"].includes(input.type) && targetSourceKey === undefined) { + if ( + ["preview_source", "switch_source", "mark_source_live", "mark_source_offline"].includes(input.type) && + targetSourceKey === undefined + ) { throw new Error(`${input.type} requires a target source key.`); } - if (input.type === "force_direct_link_fallback" && publicFallbackLinks.length === 0) { + if (input.type === "show_hold_scene" && targetSceneKey === undefined) { + throw new Error("show_hold_scene requires a target scene key."); + } + + if (["force_direct_link_fallback", "publish_fallback_link"].includes(input.type) && publicFallbackLinks.length === 0) { throw new Error("Direct-link fallback requires at least one public fallback link."); } return { type: input.type, ...(targetSourceKey === undefined ? {} : { targetSourceKey }), + ...(targetSceneKey === undefined ? {} : { targetSceneKey }), ...(targetOutputKey === undefined ? {} : { targetOutputKey }), publicFallbackLinks, ...(note === undefined ? {} : { note }), diff --git a/convex/_eventOperations.ts b/convex/_eventOperations.ts new file mode 100644 index 0000000..deb0813 --- /dev/null +++ b/convex/_eventOperations.ts @@ -0,0 +1,43 @@ +import type { Doc } from "./_generated/dataModel"; + +export function eventOperationSlot(slot: Doc<"eventSlots">) { + return { + slotId: slot._id, + position: slot.position, + startAt: slot.startAt, + ...(slot.endAt === undefined ? {} : { endAt: slot.endAt }), + displayLabel: slot.displayLabel, + roleLabel: slot.roleLabel, + reviewState: slot.reviewState, + }; +} + +export function findEventOperationSlots(slots: Doc<"eventSlots">[], now: number) { + const ordered = [...slots].sort((first, second) => first.startAt - second.startAt || first.position - second.position); + let currentSlot: Doc<"eventSlots"> | undefined; + let nextSlot: Doc<"eventSlots"> | undefined; + + for (let index = 0; index < ordered.length; index += 1) { + const slot = ordered[index]; + + if (slot === undefined) { + continue; + } + + if (nextSlot === undefined && slot.startAt > now) { + nextSlot = slot; + } + + const followingSlot = ordered[index + 1]; + const effectiveEndAt = slot.endAt ?? followingSlot?.startAt; + + if (slot.startAt <= now && (effectiveEndAt === undefined || effectiveEndAt > now)) { + currentSlot = slot; + } + } + + return { + ...(currentSlot === undefined ? {} : { currentSlot: eventOperationSlot(currentSlot) }), + ...(nextSlot === undefined ? {} : { nextSlot: eventOperationSlot(nextSlot) }), + }; +} diff --git a/convex/_generated/api.d.ts b/convex/_generated/api.d.ts index 97dae13..b8c7c0a 100644 --- a/convex/_generated/api.d.ts +++ b/convex/_generated/api.d.ts @@ -13,6 +13,7 @@ import type * as _communityAuthority from "../_communityAuthority.js"; import type * as _discordTimestamps from "../_discordTimestamps.js"; import type * as _eventInputs from "../_eventInputs.js"; import type * as _eventMediaControl from "../_eventMediaControl.js"; +import type * as _eventOperations from "../_eventOperations.js"; import type * as _eventPublic from "../_eventPublic.js"; import type * as _eventSlots from "../_eventSlots.js"; import type * as _eventSlugs from "../_eventSlugs.js"; @@ -61,6 +62,7 @@ declare const fullApi: ApiFromModules<{ _discordTimestamps: typeof _discordTimestamps; _eventInputs: typeof _eventInputs; _eventMediaControl: typeof _eventMediaControl; + _eventOperations: typeof _eventOperations; _eventPublic: typeof _eventPublic; _eventSlots: typeof _eventSlots; _eventSlugs: typeof _eventSlugs; diff --git a/convex/events.ts b/convex/events.ts index 4ffc640..9443d32 100644 --- a/convex/events.ts +++ b/convex/events.ts @@ -4,22 +4,26 @@ import type { Doc, Id } from "./_generated/dataModel"; import { mutation, query, type DatabaseReader, type DatabaseWriter, type MutationCtx, type QueryCtx } from "./_generated/server"; import { isSameAuthSubject, + subjectHasAnyCommunityCapability, subjectHasCommunityCapability, toAuthSubject, type AuthSubject, } from "./_communityAuthority"; import { + eventMediaCommandTypeValidator, eventMediaPlaybackPlatformValidator, eventMediaSessionStatusValidator, eventMediaVrcdnRegionValidator, eventMediaWorkerArtifactTypeValidator, eventMediaWorkerProviderValidator, eventMediaWorkerTaskStatusValidator, + sanitizeEventMediaCommandInput, sanitizeEventMediaWorkerArtifactLinks, sanitizeEventMediaWorkerSchedule, sanitizeVrcdnOperatorOwnedOutputSetup, } from "./_eventMediaControl"; import { sanitizeEventDraftInput } from "./_eventInputs"; +import { findEventOperationSlots } from "./_eventOperations"; import { getPublicCommunityHostedEvents, getPublicEventBySlug } from "./_eventPublic"; import { findAvailableEventSlug, getEventBySlug, validateEventSlug } from "./_eventSlugs"; import { canReadProfile } from "./_profilePermissions"; @@ -146,6 +150,16 @@ const eventMediaWorkerScheduleArgs = { artifactLinks: v.optional(v.array(eventMediaWorkerArtifactLinkInput)), }; +const eventMediaCommandArgs = { + currentSlug: v.string(), + type: eventMediaCommandTypeValidator, + targetSourceKey: v.optional(v.string()), + targetSceneKey: v.optional(v.string()), + targetOutputKey: v.optional(v.string()), + publicFallbackLinks: v.optional(v.array(eventMediaPlaybackLinkInput)), + note: v.optional(v.string()), +}; + const eventMediaWorkerSessionArgs = { currentSlug: v.string(), sessionId: v.id("eventMediaSessions"), @@ -346,6 +360,42 @@ async function canUpdateEvent( return subjectHasCommunityCapability(db, event.communityProfileId, subject, "manage_events"); } +async function canManageEventMedia( + db: DatabaseReader, + event: Doc<"events">, + subject: AuthSubject, +): Promise { + if (isSameAuthSubject(event.submitter, subject)) { + return true; + } + + if (event.communityProfileId === undefined) { + return false; + } + + return subjectHasCommunityCapability(db, event.communityProfileId, subject, "manage_event_media"); +} + +async function canViewEventOperations( + db: DatabaseReader, + event: Doc<"events">, + subject: AuthSubject, +): Promise { + if (isSameAuthSubject(event.submitter, subject)) { + return true; + } + + if (event.communityProfileId === undefined) { + return false; + } + + return subjectHasAnyCommunityCapability(db, event.communityProfileId, subject, [ + "view_event_operations", + "manage_events", + "manage_event_media", + ]); +} + async function replaceEventWorldLink( db: DatabaseWriter, eventId: Id<"events">, @@ -539,6 +589,54 @@ async function getEditableEventBySlug( return { event, slug: validation.slug }; } +async function getMediaManageableEventBySlug( + ctx: MutationCtx | QueryCtx, + currentSlug: string, + subject: AuthSubject, +): Promise<{ event: Doc<"events">; slug: string }> { + const validation = validateEventSlug(currentSlug); + + if (!validation.ok) { + throw new Error("Current event slug is invalid."); + } + + const event = await getEventBySlug(ctx.db, validation.slug); + + if (event === null) { + throw new Error("Event was not found."); + } + + if (!(await canManageEventMedia(ctx.db, event, subject))) { + throw new Error("You do not have permission to control event media."); + } + + return { event, slug: validation.slug }; +} + +async function getOperationsReadableEventBySlug( + ctx: QueryCtx, + currentSlug: string, + subject: AuthSubject, +): Promise<{ event: Doc<"events">; slug: string }> { + const validation = validateEventSlug(currentSlug); + + if (!validation.ok) { + throw new Error("Current event slug is invalid."); + } + + const event = await getEventBySlug(ctx.db, validation.slug); + + if (event === null) { + throw new Error("Event was not found."); + } + + if (!(await canViewEventOperations(ctx.db, event, subject))) { + throw new Error("You do not have permission to view event operations."); + } + + return { event, slug: validation.slug }; +} + async function getReadyEventMediaOutput( db: DatabaseReader, program: Doc<"eventMediaPrograms">, @@ -598,6 +696,7 @@ async function recordEventMediaAuditEvent( sessionId?: Id<"eventMediaSessions">; commandId?: Id<"eventMediaCommands">; outputId?: Id<"eventMediaOutputs">; + sourceId?: Id<"eventMediaSources">; actor?: AuthSubject; actorSurface?: "web" | "discord" | "worker" | "system"; action: string; @@ -611,6 +710,7 @@ async function recordEventMediaAuditEvent( eventId: input.eventId, ...(input.sessionId === undefined ? {} : { sessionId: input.sessionId }), ...(input.commandId === undefined ? {} : { commandId: input.commandId }), + ...(input.sourceId === undefined ? {} : { sourceId: input.sourceId }), ...(input.outputId === undefined ? {} : { outputId: input.outputId }), ...(input.actor === undefined ? {} : { actor: input.actor }), actorSurface: input.actorSurface ?? "web", @@ -621,6 +721,73 @@ async function recordEventMediaAuditEvent( }); } +function sourceStatusSummary(sources: Doc<"eventMediaSources">[]) { + return sources.reduce( + (summary, source) => ({ + ...summary, + [source.state]: (summary[source.state] ?? 0) + 1, + }), + {} as Partial["state"], number>>, + ); +} + +function commandStatusSummary(commands: Doc<"eventMediaCommands">[]) { + return commands.reduce( + (summary, command) => ({ + ...summary, + [command.status]: (summary[command.status] ?? 0) + 1, + }), + {} as Partial["status"], number>>, + ); +} + +async function resolveEventMediaCommandTargets( + db: DatabaseReader, + program: Doc<"eventMediaPrograms">, + command: ReturnType, +) { + const [source, scene, output] = await Promise.all([ + command.targetSourceKey === undefined + ? Promise.resolve(null) + : db + .query("eventMediaSources") + .withIndex("by_programId_key", (query) => + query.eq("programId", program._id).eq("key", command.targetSourceKey ?? ""), + ) + .unique(), + command.targetSceneKey === undefined + ? Promise.resolve(null) + : db + .query("eventMediaScenes") + .withIndex("by_programId_key", (query) => + query.eq("programId", program._id).eq("key", command.targetSceneKey ?? ""), + ) + .unique(), + command.targetOutputKey === undefined + ? Promise.resolve(null) + : db + .query("eventMediaOutputs") + .withIndex("by_programId_key", (query) => + query.eq("programId", program._id).eq("key", command.targetOutputKey ?? ""), + ) + .unique(), + ]); + + if (command.targetSourceKey !== undefined && source === null) { + throw new Error("Event media source was not found."); + } + + if (command.targetSceneKey !== undefined && scene === null) { + throw new Error("Event media scene was not found."); + } + + if (command.targetOutputKey !== undefined && output === null) { + throw new Error("Event media output was not found."); + } + + return { source, scene, output }; +} + async function insertEventMediaCommand( db: DatabaseWriter, input: { @@ -937,24 +1104,45 @@ export const listVrcdnOutputAccounts = query({ export const getEventMediaControlStatus = query({ args: { currentSlug: v.string(), + now: v.optional(v.number()), }, handler: async (ctx, args) => { const subject = await requireAuthenticatedSubject(ctx); - const { event, slug } = await getEditableEventBySlug(ctx, args.currentSlug, subject); + const { event, slug } = await getOperationsReadableEventBySlug(ctx, args.currentSlug, subject); const program = await getLatestEventMediaProgram(ctx.db, event._id); + const slots = await ctx.db + .query("eventSlots") + .withIndex("by_eventId_startAt", (query) => query.eq("eventId", event._id)) + .take(100); + const slotState = findEventOperationSlots(slots, args.now ?? Date.now()); if (program === null) { return { eventId: event._id, eventPath: `/e/${slug}`, program: null, + sources: [], outputs: [], sessions: [], + commands: [], queuedCommandCount: 0, + operationReadiness: { + hasMediaProgram: false, + readyOutputCount: 0, + activeOutputCount: 0, + sourceStates: {}, + commandStates: {}, + openSessionCount: 0, + }, + ...slotState, }; } - const [outputs, sessions, queuedCommands] = await Promise.all([ + const [sources, outputs, sessions, commands, queuedCommands] = await Promise.all([ + ctx.db + .query("eventMediaSources") + .withIndex("by_programId_position", (query) => query.eq("programId", program._id)) + .take(100), ctx.db .query("eventMediaOutputs") .withIndex("by_programId_key", (query) => query.eq("programId", program._id)) @@ -963,11 +1151,17 @@ export const getEventMediaControlStatus = query({ .query("eventMediaSessions") .withIndex("by_programId_status", (query) => query.eq("programId", program._id)) .take(50), + ctx.db + .query("eventMediaCommands") + .withIndex("by_eventId_createdAt", (query) => query.eq("eventId", event._id)) + .order("desc") + .take(50), ctx.db .query("eventMediaCommands") .withIndex("by_programId_status_createdAt", (query) => query.eq("programId", program._id).eq("status", "queued")) .take(100), ]); + const programCommands = commands.filter((command) => command.programId === program._id); return { eventId: event._id, @@ -981,6 +1175,22 @@ export const getEventMediaControlStatus = query({ directFallbackLinkCount: program.directFallbackLinks.length, updatedAt: program.updatedAt, }, + sources: sources + .sort((first, second) => first.position - second.position || first.key.localeCompare(second.key)) + .map((source) => ({ + sourceId: source._id, + key: source.key, + position: source.position, + type: source.type, + purpose: source.purpose, + state: source.state, + label: source.label, + ...(source.eventSlotId === undefined ? {} : { eventSlotId: source.eventSlotId }), + ...(source.sourceProfileId === undefined ? {} : { sourceProfileId: source.sourceProfileId }), + hasPublicUrl: source.publicUrl !== undefined, + hasPrivateConfig: source.privateConfigRef !== undefined, + updatedAt: source.updatedAt, + })), outputs: outputs .sort((first, second) => first.key.localeCompare(second.key)) .map((output) => ({ @@ -994,7 +1204,97 @@ export const getEventMediaControlStatus = query({ updatedAt: output.updatedAt, })), sessions: sessions.sort((first, second) => second.updatedAt - first.updatedAt).map(workerSessionStatus), + commands: programCommands.map((command) => ({ + commandId: command._id, + commandType: command.commandType, + status: command.status, + actorSurface: command.actorSurface, + ...(command.actor?.displayName === undefined ? {} : { actorDisplayName: command.actor.displayName }), + ...(command.targetSourceId === undefined ? {} : { targetSourceId: command.targetSourceId }), + ...(command.targetSourceKey === undefined ? {} : { targetSourceKey: command.targetSourceKey }), + ...(command.targetSceneId === undefined ? {} : { targetSceneId: command.targetSceneId }), + ...(command.targetSceneKey === undefined ? {} : { targetSceneKey: command.targetSceneKey }), + ...(command.targetOutputId === undefined ? {} : { targetOutputId: command.targetOutputId }), + ...(command.targetOutputKey === undefined ? {} : { targetOutputKey: command.targetOutputKey }), + fallbackLinkCount: command.publicFallbackLinks.length, + ...(command.note === undefined ? {} : { note: command.note }), + ...(command.errorSummary === undefined ? {} : { errorSummary: command.errorSummary }), + createdAt: command.createdAt, + updatedAt: command.updatedAt, + })), queuedCommandCount: queuedCommands.length, + operationReadiness: { + hasMediaProgram: true, + readyOutputCount: outputs.filter((output) => output.state === "ready").length, + activeOutputCount: outputs.filter((output) => output.state === "active").length, + sourceStates: sourceStatusSummary(sources), + commandStates: commandStatusSummary(programCommands), + openSessionCount: sessions.filter((session) => ["scheduled", "starting", "live", "hold", "fallback", "stopping"].includes(session.status)) + .length, + }, + ...slotState, + }; + }, +}); + +export const queueEventMediaCommand = mutation({ + args: eventMediaCommandArgs, + handler: async (ctx, args) => { + const subject = await requireAuthenticatedSubject(ctx); + const { event, slug } = await getMediaManageableEventBySlug(ctx, args.currentSlug, subject); + const program = await getLatestEventMediaProgram(ctx.db, event._id); + + if (program === null) { + throw new Error("Event media program was not found."); + } + + const command = sanitizeEventMediaCommandInput(args); + + if (command.type === "start_program" || command.type === "stop_program") { + throw new Error("Use the worker lifecycle mutations for start_program and stop_program commands."); + } + + const targets = await resolveEventMediaCommandTargets(ctx.db, program, command); + const now = Date.now(); + const commandId = await ctx.db.insert("eventMediaCommands", { + programId: program._id, + eventId: event._id, + commandType: command.type, + status: "queued", + actor: subject, + actorSurface: "web", + ...(targets.source === null ? {} : { targetSourceId: targets.source._id }), + ...(command.targetSourceKey === undefined ? {} : { targetSourceKey: command.targetSourceKey }), + ...(targets.scene === null ? {} : { targetSceneId: targets.scene._id }), + ...(command.targetSceneKey === undefined ? {} : { targetSceneKey: command.targetSceneKey }), + ...(targets.output === null ? {} : { targetOutputId: targets.output._id }), + ...(command.targetOutputKey === undefined ? {} : { targetOutputKey: command.targetOutputKey }), + publicFallbackLinks: command.publicFallbackLinks, + ...(command.note === undefined ? {} : { note: command.note }), + createdAt: now, + updatedAt: now, + }); + + await recordEventMediaAuditEvent(ctx.db, { + programId: program._id, + eventId: event._id, + commandId, + ...(targets.source === null ? {} : { sourceId: targets.source._id }), + ...(targets.output === null ? {} : { outputId: targets.output._id }), + actor: subject, + action: "media_command_queued", + publicSummary: `Event media command queued: ${command.type}.`, + ...(command.note === undefined ? {} : { privateSummary: command.note }), + createdAt: now, + }); + + return { + eventId: event._id, + eventPath: `/e/${slug}`, + programId: program._id, + commandId, + status: "queued" as const, + commandType: command.type, }; }, }); @@ -1293,21 +1593,7 @@ export const configureVrcdnOutput = mutation({ args: vrcdnOutputSetupArgs, handler: async (ctx, args) => { const subject = await requireAuthenticatedSubject(ctx); - const validation = validateEventSlug(args.currentSlug); - - if (!validation.ok) { - throw new Error("Current event slug is invalid."); - } - - const event = await getEventBySlug(ctx.db, validation.slug); - - if (event === null) { - throw new Error("Event was not found."); - } - - if (!(await canUpdateEvent(ctx.db, event, subject))) { - throw new Error("You do not have permission to update this event."); - } + const { event, slug } = await getMediaManageableEventBySlug(ctx, args.currentSlug, subject); const account = args.outputAccountKey === undefined ? undefined : getVrcdnOutputAccount(args.outputAccountKey); @@ -1366,7 +1652,7 @@ export const configureVrcdnOutput = mutation({ return { eventId: event._id, - eventPath: `/e/${validation.slug}`, + eventPath: `/e/${slug}`, programId, outputId, state: output.state, @@ -1379,7 +1665,7 @@ export const scheduleEventMediaWorker = mutation({ args: eventMediaWorkerScheduleArgs, handler: async (ctx, args) => { const subject = await requireAuthenticatedSubject(ctx); - const { event, slug } = await getEditableEventBySlug(ctx, args.currentSlug, subject); + const { event, slug } = await getMediaManageableEventBySlug(ctx, args.currentSlug, subject); const program = await getLatestEventMediaProgram(ctx.db, event._id); if (program === null) { @@ -1484,7 +1770,7 @@ export const recordEventMediaWorkerTaskStatus = mutation({ args: eventMediaWorkerTaskStatusArgs, handler: async (ctx, args) => { const subject = await requireAuthenticatedSubject(ctx); - const { event, slug } = await getEditableEventBySlug(ctx, args.currentSlug, subject); + const { event, slug } = await getMediaManageableEventBySlug(ctx, args.currentSlug, subject); const program = await getLatestEventMediaProgram(ctx.db, event._id); if (program === null) { @@ -1614,7 +1900,7 @@ export const stopEventMediaWorker = mutation({ args: eventMediaWorkerSessionArgs, handler: async (ctx, args) => { const subject = await requireAuthenticatedSubject(ctx); - const { event, slug } = await getEditableEventBySlug(ctx, args.currentSlug, subject); + const { event, slug } = await getMediaManageableEventBySlug(ctx, args.currentSlug, subject); const program = await getLatestEventMediaProgram(ctx.db, event._id); if (program === null) { @@ -1678,7 +1964,7 @@ export const markEventMediaWorkerEnded = mutation({ }, handler: async (ctx, args) => { const subject = await requireAuthenticatedSubject(ctx); - const { event, slug } = await getEditableEventBySlug(ctx, args.currentSlug, subject); + const { event, slug } = await getMediaManageableEventBySlug(ctx, args.currentSlug, subject); const program = await getLatestEventMediaProgram(ctx.db, event._id); if (program === null) { diff --git a/convex/schema.ts b/convex/schema.ts index 5f2ee5a..1dcc5db 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -259,8 +259,12 @@ const eventSlotReviewState = v.union( ); const communityCapability = v.union( + v.literal("edit_community_profile"), v.literal("manage_profile"), + v.literal("manage_roster"), v.literal("manage_events"), + v.literal("manage_event_media"), + v.literal("view_event_operations"), v.literal("manage_staff"), v.literal("manage_integrations"), v.literal("manage_billing"), @@ -742,6 +746,8 @@ export default defineSchema({ purpose: eventMediaSourcePurposeValidator, state: eventMediaSourceStateValidator, label: v.string(), + eventSlotId: v.optional(v.id("eventSlots")), + sourceProfileId: v.optional(v.id("profiles")), ownerProfileId: v.optional(v.id("profiles")), publicUrl: v.optional(v.string()), privateConfigRef: v.optional(v.string()), @@ -796,6 +802,8 @@ export default defineSchema({ actorSurface: eventMediaActorSurfaceValidator, targetSourceId: v.optional(v.id("eventMediaSources")), targetSourceKey: v.optional(v.string()), + targetSceneId: v.optional(v.id("eventMediaScenes")), + targetSceneKey: v.optional(v.string()), targetOutputId: v.optional(v.id("eventMediaOutputs")), targetOutputKey: v.optional(v.string()), publicFallbackLinks: v.array(eventMediaPublicLinkValidator), diff --git a/docs/backend/event-schema.md b/docs/backend/event-schema.md index 025c890..1ea4655 100644 --- a/docs/backend/event-schema.md +++ b/docs/backend/event-schema.md @@ -2,7 +2,7 @@ ## Status -Current recommendation and implementation note for `#34`, `#35`, `#36`, `#119`, `#132`, and `#134`. +Current recommendation and implementation note for `#34`, `#35`, `#36`, `#93`, `#119`, `#123`, `#124`, `#132`, and `#134`. ## Event Records @@ -44,7 +44,22 @@ A small `communityAuthorities` table is reserved for the next authority layer: - familiar starter roles such as `admin` and `mod` - capability flags such as `manage_events` -The fuller ownership and staff-role foundation is tracked in `#93`. +Current recommendation: split the singleton ownership link from delegable staff-role assignments. `owner` is a special community authority state, not an ordinary role row. Non-owner role assignments can start with seeded role keys such as `admin` and `mod`, but backend checks should use capability flags so the role vocabulary can evolve. + +Starter capabilities: + +- `edit_community_profile`: edit public community profile fields and presentation. +- `manage_roster`: add, remove, and annotate community roster members. +- `manage_events`: create and edit community events, slots, lineup links, event-world links, and public event metadata. +- `manage_event_media`: configure event media programs, sources, outputs, worker lifecycle, fallback publication, and media-control commands. +- `view_event_operations`: read private current/next slot, readiness, source status, and command history without editing. +- `manage_staff`: invite, assign, revoke, and audit non-owner staff roles. +- `manage_integrations`: configure community-owned import/export or partner integration settings. +- `manage_billing`: access ordinary community billing settings where product policy allows. + +Owner-only actions include ownership transfer, owner removal, destructive community deletion/suppression, capability policy changes that could remove owner control, and any final sensitive billing authority that can terminate or transfer the community's account-level relationship. Ownership transfer should require an explicit acceptance flow rather than a silent reassignment. + +Event writes should authorize the original submitter during the first slice, then prefer community authority when a host community is attached. Event media-control calls require `manage_event_media` or a scoped event token; read-only operator panels can use `view_event_operations`. The fuller ownership and staff-role foundation is tracked in `#93`. ## Event Participants @@ -85,6 +100,33 @@ Canonical slot times are stored as timestamps. Discord timestamp tokens such as The first slot editor uses relative minute offsets from the event start for operator-friendly sequential scheduling. Backend storage still receives absolute timestamps after the operator confirms the event start and a valid IANA timezone. +## Event Operations Panel + +The private operator command roster is separate from the public event page. It reads canonical event, slot, participant, world, and media-control records, then presents an event-running view for authorized staff. + +Operator rows should show: + +- current, next, and upcoming slot position. +- scheduled start/end times and overrun state. +- linked public performer profile when available, otherwise the public slot display label. +- private readiness state: `ready`, `needs_attention`, `not_ready`, or `unknown`. +- private source state when a media source exists, using the event media-control status vocabulary. +- private operator notes, last updated actor, and provenance/freshness for any advisory signals. + +Manual panel actions should include: + +- mark performer readiness. +- cue next, previous, or custom slot/source. +- preview a media source. +- hold current source or show a hold scene. +- publish a fallback watch link. +- copy or preview Discord-ready lineup output. +- add private operator notes. + +The panel should separate manual actions from automatic/advisory signals. A local VRChat bridge can provide private hints, such as resolving a VRChat user/world/group or suggesting that a performer may be in a relevant instance, only when an operator runs an approved local bridge with appropriate credentials. Bridge-derived status is never a public fact, never required for event operation, and never sufficient for profile claim or public readiness. Public pages continue to project only safe event, slot, profile, image, and watch-surface data. + +Authorization is capability-based: `view_event_operations` can read the panel, `manage_events` can edit schedule/roster data, and `manage_event_media` can send media/source/output commands. Every write should create an audit event with actor, capability or token scope, target row, command/action, result, and sanitized reason. + ## Event Media Slots Events currently support three public image slots: @@ -158,8 +200,84 @@ Reserved control-plane tables include: - `eventMediaSessions` for concrete worker runs, leases, current source/scene, health heartbeats, task status, scheduled start, ready-by deadline, stop request, and private artifact/report links. - `eventMediaAuditEvents` for immutable operator and automation history tied back to events, programs, sessions, commands, sources, and outputs. +### Source Routing Model + +`eventMediaSources` stores event-scoped inputs and fallbacks. A source can optionally point to an `eventSlots` row, a public person profile, or an operator-authored display label. Source records should be able to represent: + +- performer-provided stream or watch links. +- VJ, host, or venue camera feeds. +- provider-normalized public playback links such as VRCDN, Twitch, YouTube, HLS, or direct file playback. +- private runtime inputs that are represented only by secret references. +- hold scenes, offline scenes, intro/outro scenes, image slates, and audio loops. +- direct fallback watch links that can be published when hosted output is unavailable. + +Public playback URLs may be stored only when they contain no embedded credential, signature, query secret, or userinfo. Ingest URLs, stream keys, provider tokens, signed URLs, passwords, and combined credential URLs must stay in the configured secret store and appear in Convex only as scoped reference names. + +Sources can carry public-safe labels and thumbnail hints, but public pages should derive performer identity from published `eventSlots` and public person-profile projections when possible. A current source attached to a public slot can expose `Now playing` with the slot label, public performer display name, and public profile thumbnail/banner fallback from the event card media model. It must not expose private readiness notes, provider health, raw source URLs, worker ids, or VRChat presence signals. + +### Source Status + +The control plane should keep relationship state and liveness state separate: + +- `current`: selected by the active route or active session. +- `next`: selected by the upcoming slot, operator cue, or rule evaluation. +- `live`: a trusted adapter, worker probe, or operator confirmation says the source is currently usable. +- `offline`: a trusted adapter, worker probe, or operator confirmation says the source is not usable. +- `stale`: the last trusted status is older than the configured freshness window. +- `unknown`: no trusted status exists, the provider cannot be checked, or the check failed closed. + +`current` and `next` describe routing position; `live`, `offline`, `stale`, and `unknown` describe usability. Automatic rules should require `live` before switching to a source. `unknown` and `stale` are safe for preview/manual confirmation, but should block automatic switching unless an operator explicitly confirms the command. + +### Manual Controls + +Manual operator controls are the first shippable command layer. They should work before automatic switching exists and can start as preview-only controls that create auditable command records without mutating a live worker. + +The command vocabulary should include: + +- `preview_source`: test a source privately without changing the public output. +- `switch_next`: switch to the cued next source. +- `switch_previous`: return to the previous source when rollback is safer than holding. +- `switch_source`: switch to an explicit custom source id. +- `hold_current`: keep the current source active and suppress automatic switching. +- `show_hold_scene`: move output to a hold, offline, intro, outro, or slate scene. +- `publish_fallback_link`: expose a public direct fallback link through the event watch surface. +- `start_program` and `stop_program`: manage the hosted worker session when one is configured. + +Every command should record actor type, actor id or token id, requested source or scene, intended result, validation result, execution result, timestamp, and a sanitized reason when rejected. Rejections should avoid provider-specific secret-bearing detail. + +### Automatic Rule Candidates + +Automatic switching is a later rule layer, not the baseline control model. Rules should evaluate current schedule, slot overrun, source status, operator holds, and provider freshness before emitting a normal command. + +Candidate rules include: + +- switch when the next performer source is `live` and the current source is `offline`. +- switch when the current slot is past its end plus a configured grace period and the next performer source is `live`. +- keep holding the current source when the next source is `unknown`, `stale`, or `offline`. +- move to a configured hold scene when the current source is `offline` and no confirmed next source exists. +- publish a direct fallback watch link when hosted output is unavailable but a safe public performer or venue link exists. + +Automatic rules must never bypass an operator-level hold, consent gate, destination authority gate, or source-specific block. Rule output should enqueue the same `eventMediaCommands` records used by manual controls so audit and rollback behavior stay consistent. + +### Authorization And Tokens + +Interactive controls require a signed-in editor with event authority, such as the event submitter in the first slice or a later community `manage_events` / `manage_event_media` capability. Worker, bridge, Discord, or external command surfaces use scoped event tokens rather than broad user sessions. + +Scoped tokens should carry: + +- event id and optional program id. +- allowed command verbs. +- allowed source, scene, or output ids when the token is narrower than the whole event. +- actor label for audit display. +- issued-at, expires-at, and revoked-at timestamps. +- last-used metadata and rotation state. + +Tokens authorize command submission, not secret retrieval. Runtime secret access remains limited to the bridge or worker environment through the approved secret-reference map. Public pages never receive tokens, token ids, secret references, or command queue internals. + Public projection must stay narrow: public surfaces can show safe status, current source/output labels, public watch links, and direct fallback links. They must not expose worker identifiers, command queue internals, secret references, private setup notes, ingest URLs, stream keys, or provider-specific failure mechanics. +For `Now playing`, public projection should use only published event/slot data and public profile/image projections. If the active source is not attached to a published slot or safe label, the public page should fall back to the event title, host, or generic watch card instead of leaking private operator labels. Public output can say who is currently playing, but it should not say whether the next performer is late, offline, stale, missing, or privately marked not ready. + ### Worker Scheduling Current recommendation: schedule one worker session per event media program. `events.scheduleEventMediaWorker` requires a `ready` output, creates or updates the scheduled session, and queues a `start_program` command for the runtime. By default the worker is scheduled for `T-5 minutes` and must be ready by `T-2 minutes`, where `T` is the event start time. Custom values are accepted only when the scheduled start is before the event start and the ready deadline is at or after the scheduled start but still before the event start. diff --git a/docs/deployment/restream-worker.md b/docs/deployment/restream-worker.md index ea7de58..fe9dcd9 100644 --- a/docs/deployment/restream-worker.md +++ b/docs/deployment/restream-worker.md @@ -52,6 +52,8 @@ Secret values are not environment variables in git or Terraform. The `secret_arn Current recommendation: run `pnpm ops:event-media:ecs-bridge` from an operator-controlled environment with AWS credentials that can register task definitions, run tasks, describe tasks, stop tasks, and deregister the bridge-created task definitions. The bridge polls Convex for queued `start_program` and `stop_program` commands, starts or stops one Fargate task, then records task status back into Convex for the private event editor. +The bridge and worker do not own event media-control policy. Convex remains the source of truth for manual commands, candidate automatic-rule output, source routing state, and audit records. A worker receives only the selected source/output/session payload required for the command it claimed; it must not infer broader event permissions or publish private source-health detail to public pages. + Required non-secret bridge configuration: - `CONVEX_URL` diff --git a/docs/developers/vrdex-mcp-read-tools.md b/docs/developers/vrdex-mcp-read-tools.md index bc66c25..09c1fa8 100644 --- a/docs/developers/vrdex-mcp-read-tools.md +++ b/docs/developers/vrdex-mcp-read-tools.md @@ -14,6 +14,7 @@ The standalone VRDex MCP should wait for stable public API/query behavior. This - Use compact outputs with stable IDs/slugs for follow-up calls. - Preserve public visibility, opt-out, trust, and provenance rules. - Do not expose authenticated claim/write operations in the first read-only slice. +- Keep event-operator presence/readiness signals out of the standalone public read tool contract. ## Candidate Tools @@ -142,6 +143,13 @@ Candidate direction: - local MCP remains useful for self-hosted deployments and development - authenticated write/claim tools, if ever added, need normal VRDex auth, scoped tokens, approvals, and audit trails +Optional VRChat bridge evaluation: + +- a local bridge can be evaluated separately for operator-owned event workflows, not for the standalone public read tools +- candidate bridge tools can resolve VRChat users, groups, or worlds to candidate VRDex records, or provide private event-operator hints when the operator has local credentials +- bridge-derived presence or readiness must be treated as private, freshness-scoped, and non-authoritative +- bridge tools must not be required for `vrdex_event_get`, event discovery, profile claims, or public event watch surfaces + ## Implementation Gate Do not implement the standalone package until the public API/query shape supports these tools without scraping. [#78](https://github.com/BASIC-BIT/VRDex/issues/78) remains the prototype issue and should choose package name, transport, auth posture, API dependencies, test fixtures, and distribution path. diff --git a/docs/planning/agent-integration-surface.md b/docs/planning/agent-integration-surface.md index f089d9f..5d7d61d 100644 --- a/docs/planning/agent-integration-surface.md +++ b/docs/planning/agent-integration-surface.md @@ -110,6 +110,14 @@ Candidate direction: - consider optional bridge tools in VRChat MCP later when cross-context workflows are compelling, such as resolving a VRChat group/user/world to a VRDex profile or opening a VRDex event/profile from VRChat context - do not put VRDex partner data or VRDex claim operations behind VRChat cookies +Phased recommendation for event workflows: + +- yes to evaluating a local-only bridge for operator workflows after standalone VRDex read tools exist +- no to hosting VRChat credential-backed bridge tools as a public VRDex service +- no to using bridge-derived presence/readiness as public event data, profile claim evidence, or required event workflow input +- first concrete tools should be narrow: resolve a VRChat user/group/world to candidate VRDex records, open a VRDex profile/event from VRChat context, and provide private freshness-scoped event-operator hints such as whether a performer may be in a relevant instance +- every bridge signal needs provenance, freshness, and visibility metadata so operators can tell local hints from owner-confirmed VRDex facts + ## Safety And Trust Rules - Agent-facing docs and skills must preserve VRDex's source/provenance model. @@ -117,6 +125,7 @@ Candidate direction: - Partner imports should not encourage dumping raw spreadsheets into repos or public APIs. - Website navigation guidance should discourage scraping when an API or MCP endpoint exists. - Any authenticated MCP write or claim tool should be approval-friendly, auditable, and scoped. +- Local VRChat bridge tools must not export private cookies, private presence, hidden group data, or operator-only readiness into public VRDex records. ## Rollout Order diff --git a/docs/planning/architecture.md b/docs/planning/architecture.md index 143db82..08a1e74 100644 --- a/docs/planning/architecture.md +++ b/docs/planning/architecture.md @@ -214,17 +214,23 @@ Candidate later direction: - community-scoped roles for management and collaboration - likely starter defaults: `admin`, `mod` - should be treated as default or seed roles, not necessarily permanent hard-coded product roles +- `owner` is not stored as an ordinary community role; it is the singleton ownership link for the community +- role assignments need active/revoked state and audit metadata so staff changes are reversible and explainable ### `community_role_permissions` - capability mapping for community actions - examples: edit profile, manage staff, manage events, transfer ownership, manage billing +- starter capability keys: `edit_community_profile`, `manage_roster`, `manage_events`, `manage_event_media`, `view_event_operations`, `manage_staff`, `manage_integrations`, and `manage_billing` Current recommendation: - `owner` is modeled as a special singleton ownership state, not just another ordinary role - admins can manage billing by default - dangerous ownership-sensitive actions should stay owner-only +- `manage_events` should authorize event CRUD, slot/lineup edits, event-world associations, and public event metadata +- `manage_event_media` should authorize private media-control commands, output setup, source routing, fallback publication, and worker lifecycle actions +- `view_event_operations` should allow read-only access to private current/next slot, readiness, source status, and command history without allowing writes ### `verification_events` @@ -385,6 +391,30 @@ Current recommendation: - provider embeds are allow-listed for YouTube, Twitch, and VRCDN; unsupported watch links remain outbound cards - provider live/offline checks belong to the later restream/media-control model in `#124` and should not leak into viewer-facing explanatory copy +### `event_media_control` later + +- event-scoped restream and one-link routing records should sit beside, not inside, the public media-link list +- sources can represent performer streams, VJ feeds, venue cameras, hold slates, intro/outro scenes, and direct public fallback links +- sources can attach to event slots so the current route can derive public `Now playing` from safe slot/profile state +- manual operator commands include preview, next, previous, custom source switch, hold current, hold slate, fallback publication, start, and stop +- automatic rules are separate from commands and should start as candidate policy, such as switching only when the next source is live and the current source is offline or over a configured grace period +- source status uses a small shared vocabulary: `current`, `next`, `live`, `offline`, `stale`, and `unknown` +- stale or unknown sources should not be automatic-switch targets without explicit operator confirmation +- media-control calls require a signed-in editor with event management authority, or a scoped event token for a worker, bridge, or approved command surface +- all accepted, rejected, and expired commands should write audit events with actor, token scope, command intent, result, and sanitized reason +- public projection is limited to safe watch links, public current performer labels, public profile imagery, and optional `Now playing`; private readiness, provider health, secret references, and command internals stay out of public pages + +### `event_operations` later + +- private operator view over canonical events, slots, participants, worlds, and media-control state +- roster rows show current, next, and upcoming slots with schedule times, overrun state, linked public profile, display label, and operator-only readiness +- readiness vocabulary starts small: `ready`, `needs_attention`, `not_ready`, and `unknown` +- source/operator status can reference the media-control vocabulary without exposing provider mechanics publicly +- manual actions include mark readiness, cue slot/source, hold current, show hold scene, preview source, publish fallback, copy Discord-ready output, and add private operator notes +- automatic signals are advisory until converted into an audited command by an operator or a separately configured rule +- authorization uses community capabilities: `view_event_operations` for read-only panels, `manage_events` for schedule/roster edits, and `manage_event_media` for source/output control +- optional local bridge signals from VRChat tooling can appear only as private operator hints with freshness and provenance, never as public attendance/readiness claims + ### `entity_match_suggestions` - stores LLM or rule-based candidate matches from event descriptions @@ -502,6 +532,8 @@ Current recommendation: - model one special `owner` plus seeded familiar roles like `admin` and `mod` - allow the non-owner role structure to evolve rather than treating every role name as permanently hard-coded - use capability flags for actions and keep the first set intentionally small +- keep ownership transfer, owner removal, destructive community actions, and final sensitive billing authority owner-only +- split event authority into `manage_events`, `manage_event_media`, and `view_event_operations` so schedule editors, stream operators, and helpers do not need identical access Reasoning: @@ -535,6 +567,13 @@ Best reuse targets: - VRChat group reads - local-first tooling for admin and support workflows +Event bridge boundary: + +- local-only VRChat bridge tools may help an operator resolve VRChat users, groups, worlds, and event context to VRDex records +- a local bridge may provide private freshness-scoped hints such as `performer_maybe_in_instance` when the operator has appropriate local credentials and consent boundaries +- VRDex public pages, claim flows, and hosted services must not depend on private VRChat cookies or local presence checks +- bridge-derived signals are advisory operator context, not authoritative event facts or public readiness states + ### VRC Pop Best integration stance: diff --git a/docs/planning/engineering-strategy.md b/docs/planning/engineering-strategy.md index c6b6af6..9a2e7b2 100644 --- a/docs/planning/engineering-strategy.md +++ b/docs/planning/engineering-strategy.md @@ -115,6 +115,7 @@ Current recommendation: - design API docs as both human-readable and agent-consumable, with task examples and machine-readable schema docs - treat a portable VRDex skill as a product integration artifact for external repos, separate from repo-local onboarding skills used by VRDex maintainers - keep a standalone VRDex MCP as the default long-term direction, with optional VRChat MCP bridge tools only where cross-context workflows justify the coupling +- keep any VRChat credential-backed bridge local/operator-owned; do not make public VRDex services depend on private VRChat cookies, local presence, or bridge-only context Agent-facing integration direction: @@ -122,6 +123,7 @@ Agent-facing integration direction: - public docs should include stable route/API patterns, trust/provenance rules, and examples for partner agents - website navigation guidance should exist, but structured data should prefer API or MCP over scraping - a future VRDex MCP should use the VRChat MCP pattern of curated tools first, generated API coverage second, compact outputs, and IDs/slugs for follow-up calls +- optional bridge tools should expose provenance and freshness, and should feed private operator workflows rather than public profile/event facts Infra direction: diff --git a/docs/planning/prd.md b/docs/planning/prd.md index 9976bfb..7037fa8 100644 --- a/docs/planning/prd.md +++ b/docs/planning/prd.md @@ -310,6 +310,9 @@ Current recommendation: - treat `owner` as the only reserved/special role - seed communities with familiar default roles like `admin` and `mod` +- use capability flags for checks, with a starter set of `edit_community_profile`, `manage_roster`, `manage_events`, `manage_event_media`, `view_event_operations`, `manage_staff`, `manage_integrations`, and `manage_billing` +- let `manage_events` cover event creation, schedule, lineup, and public event metadata, while `manage_event_media` covers restream/source/output control +- allow `view_event_operations` without granting write authority so helpers can monitor current/next slots and private readiness without controlling output - let admins manage billing by default, while keeping the most dangerous ownership-sensitive billing actions constrained - avoid hard-coding every non-owner role forever; role structure should be able to evolve @@ -469,7 +472,15 @@ Streaming and world-awareness direction: - world linkage should also power creator attribution, world pages, and event-derived active-world discovery - early Hot Worlds / Active Venues surfaces should use explicit event-world associations, curated picks, or partner-provided data with review rather than scraped global popularity - DJ/media links may need multiple variants, especially VRCDN PC vs Quest behavior -- a later restreamer or one-link stream-routing workflow may need current/next source status, live checks, operator preview, direct Twitch/watch-link access, and scheduled/manual switching +- a later restreamer or one-link stream-routing workflow should model event-scoped sources for performer streams, VJ feeds, venue cameras, hold slates, and direct fallback links +- manual controls and automatic rules should be separate product layers; first-slice controls can be manual or preview-only before scheduled/automatic switching is trusted +- manual controls should include preview, next, previous, custom source, hold current, hold slate, and fallback-link publication +- candidate automatic rules include switching only when the next performer is live and the current source is offline, or when the current slot is beyond a configured grace period and the next source is live +- source state should distinguish `current`, `next`, `live`, `offline`, `stale`, and `unknown`; unknown or stale sources should block automatic switching unless an operator confirms the command +- event media-control operations should require operator ownership or a scoped event token, and should create auditable command records without exposing source secrets or provider internals publicly +- restream output should plug into the existing public watch surface, while public `Now playing` is derived from safe event-slot/profile state and can pair the current performer with public thumbnail or banner imagery +- private event operations should have a command roster separate from the public event page, showing current and next slots, schedule drift, performer readiness, source status, and manual actions +- optional VRChat context from a local bridge can be advisory for operators, but it should not be hosted by VRDex as a public credential-backed service or exposed as public presence/readiness - more advanced stream/player knowledge is valuable, but can land after the core event model exists ## Low-priority R&D ideas diff --git a/docs/planning/product-spec.md b/docs/planning/product-spec.md index 664b68e..de405aa 100644 --- a/docs/planning/product-spec.md +++ b/docs/planning/product-spec.md @@ -345,9 +345,12 @@ MVP-adjacent but worth designing early: Club management direction: -- one owner in v1 -- familiar starter roles like `admin` and `mod` +- one owner in v1, modeled as a singleton ownership state rather than an ordinary role +- familiar starter roles like `admin` and `mod`, backed by capability flags instead of a giant permission matrix +- first capabilities should cover community profile editing, roster management, event management, event media/control operations, staff management, integrations, and billing access +- dangerous ownership actions such as transfer, owner removal, deletion, and final billing authority stay owner-only until there is a stronger transfer/acceptance flow - unclaimed roster members allowed so communities can use the system before full ecosystem adoption +- role labels can evolve later; the first product should not permanently hard-code every non-owner role name Candidate later workflow direction: @@ -404,10 +407,20 @@ Important future-aware extensions: - VRChat world linkage - platform compatibility hints - richer DJ slot breakdowns and booking-manager UX beyond the first `#119` slot editor +- private operator command roster that shows current slot, next slot, scheduled times, overrun state, performer readiness, source status, and manual actions without leaking operational status to public pages - stream/watch link modeling - set/performance artifacts that can attach an external or hosted recording to a specific event slot - calendar import, export, and sync, preserving static `.ics` export, later Google Calendar sync, and reviewed Google Calendar import; see `docs/planning/calendar-integration.md` +Event operations direction: + +- use structured slots as the source of truth for current and next performer rows +- keep operator readiness states private, such as `ready`, `needs_attention`, `not_ready`, and `unknown` +- support manual operator actions such as marking readiness, cueing next/previous/custom slots, adding private notes, copying Discord-ready output, and sending media-control commands when a media program exists +- keep automatic signals advisory until an operator or approved rule turns them into an auditable command +- require community event authority for privileged actions; `manage_events` can edit schedule/roster data, while `manage_event_media` controls restream/source operations +- optional local VRChat bridge signals may help operators resolve users, groups, worlds, or likely instance presence, but those signals are not public facts and must not become a dependency for ordinary event workflows + Event media direction: - use shared media slots across people, communities, worlds, and events instead of adding unrelated image fields for every surface @@ -456,7 +469,13 @@ Streaming and media direction: Candidate restreamer / one-link routing direction: - some communities may want one stable public stream/watch link while operators manage per-DJ source links behind it -- useful operations include manual switching, later time-boundary switching, live checks before switching, current/next source status, preview, and direct Twitch/watch-link access +- the media-control model should treat performer stream links, VJ feeds, venue cameras, hold slates, and direct fallback links as event-scoped sources that can optionally attach to event slots +- manual operator controls come first: preview a source, switch to next, switch to previous, switch to a custom source, publish a fallback link, hold the current source, or move to a hold slate +- automatic switching should remain a candidate layer, separate from manual controls, with rules such as `next performer live and current offline`, or `current slot over grace period and next performer live` +- source status should distinguish `current`, `next`, `live`, `offline`, `stale`, and `unknown`; automatic rules should not switch to an unknown source without operator confirmation +- fallback behavior should prefer holding the current source when safe, otherwise move to a hold scene or direct public fallback link while keeping private source-health detail in the operator view +- control operations should require an event-scoped key or scoped token tied to an operator, worker, Discord command surface, or bridge, with all accepted/rejected commands recorded in an audit trail +- restream output should reuse the public watch surface instead of creating a separate viewer path; public pages can show `Now playing` from the current slot, public performer profile display name, and safe thumbnail/banner imagery without exposing private readiness or provider health - this should inform event media-link modeling and operator-dashboard interviews before becoming first-slice streaming infrastructure Candidate set/performance artifact direction: diff --git a/tests/backend/community-authority.test.ts b/tests/backend/community-authority.test.ts new file mode 100644 index 0000000..232afbe --- /dev/null +++ b/tests/backend/community-authority.test.ts @@ -0,0 +1,96 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import type { Id } from "../../convex/_generated/dataModel"; +import type { DatabaseReader } from "../../convex/_generated/server"; +import { + subjectHasAnyCommunityCapability, + subjectHasCommunityCapability, + type AuthSubject, + type CommunityCapability, +} from "../../convex/_communityAuthority"; + +type AuthorityRow = { + communityProfileId: Id<"profiles">; + subjectTokenIdentifier: string; + state: "active" | "revoked"; + capabilities: CommunityCapability[]; +}; + +function createCommunityAuthorityDb(authorities: AuthorityRow[]) { + return { + query(table: string) { + assert.equal(table, "communityAuthorities"); + + return { + withIndex(_index: string, builder: (query: unknown) => unknown) { + const values: Record = {}; + const query = { + eq(field: string, value: unknown) { + values[field] = value; + return query; + }, + }; + + builder(query); + + return { + async take(limit: number) { + return authorities + .filter((authority) => Object.entries(values).every(([field, value]) => authority[field as keyof AuthorityRow] === value)) + .slice(0, limit); + }, + }; + }, + }; + }, + } as unknown as DatabaseReader; +} + +describe("community authority helpers", () => { + const communityProfileId = "community123" as Id<"profiles">; + const subject: AuthSubject = { + tokenIdentifier: "issuer|subject", + issuer: "issuer", + subject: "subject", + }; + + it("keeps legacy profile-management capabilities compatible with the new edit capability", async () => { + const db = createCommunityAuthorityDb([ + { + communityProfileId, + subjectTokenIdentifier: subject.tokenIdentifier, + state: "active", + capabilities: ["manage_profile"], + }, + ]); + + assert.equal(await subjectHasCommunityCapability(db, communityProfileId, subject, "edit_community_profile"), true); + assert.equal(await subjectHasCommunityCapability(db, communityProfileId, subject, "manage_events"), false); + }); + + it("checks all active role rows for split event operations capabilities", async () => { + const db = createCommunityAuthorityDb([ + { + communityProfileId, + subjectTokenIdentifier: subject.tokenIdentifier, + state: "active", + capabilities: ["manage_roster"], + }, + { + communityProfileId, + subjectTokenIdentifier: subject.tokenIdentifier, + state: "active", + capabilities: ["manage_event_media"], + }, + ]); + + assert.equal( + await subjectHasAnyCommunityCapability(db, communityProfileId, subject, [ + "view_event_operations", + "manage_event_media", + ]), + true, + ); + }); +}); diff --git a/tests/backend/event-foundation.test.ts b/tests/backend/event-foundation.test.ts index e6789b2..fdefd88 100644 --- a/tests/backend/event-foundation.test.ts +++ b/tests/backend/event-foundation.test.ts @@ -5,6 +5,7 @@ import type { Doc } from "../../convex/_generated/dataModel"; import type { DatabaseReader } from "../../convex/_generated/server"; import { createDiscordTimestampSet, toDiscordTimestamp } from "../../convex/_discordTimestamps"; import { sanitizeEventDraftInput } from "../../convex/_eventInputs"; +import { findEventOperationSlots } from "../../convex/_eventOperations"; import { getPublicEventPreviews, toPublicEvent, @@ -350,6 +351,59 @@ describe("event slot helpers", () => { ); assert.equal(slots[0]?.endAt, startAt + 45 * 60_000); }); + + it("derives the current operation slot from the latest started slot when ends are omitted", () => { + const startAt = Date.UTC(2026, 5, 14, 22, 0, 0); + const slots = [ + { + _id: "slot-1", + eventId: "event123", + eventStartAt: startAt, + position: 0, + startAt, + displayLabel: "DJ Aurora", + roleLabel: "House", + sourceType: "community", + sourceLabel: "Fixture lineup", + confidence: 1, + reviewState: "confirmed", + updatedAt: startAt, + }, + { + _id: "slot-2", + eventId: "event123", + eventStartAt: startAt, + position: 1, + startAt: startAt + 3_600_000, + displayLabel: "DJ Lumen", + roleLabel: "Trance", + sourceType: "community", + sourceLabel: "Fixture lineup", + confidence: 1, + reviewState: "confirmed", + updatedAt: startAt, + }, + { + _id: "slot-3", + eventId: "event123", + eventStartAt: startAt, + position: 2, + startAt: startAt + 7_200_000, + displayLabel: "DJ Nova", + roleLabel: "Techno", + sourceType: "community", + sourceLabel: "Fixture lineup", + confidence: 1, + reviewState: "confirmed", + updatedAt: startAt, + }, + ] as unknown as Doc<"eventSlots">[]; + + const operationSlots = findEventOperationSlots(slots, startAt + 3_900_000); + + assert.equal(operationSlots.currentSlot?.displayLabel, "DJ Lumen"); + assert.equal(operationSlots.nextSlot?.displayLabel, "DJ Nova"); + }); }); describe("Discord timestamp helpers", () => { diff --git a/tests/backend/event-media-control.test.ts b/tests/backend/event-media-control.test.ts index a78ce9f..8928758 100644 --- a/tests/backend/event-media-control.test.ts +++ b/tests/backend/event-media-control.test.ts @@ -73,6 +73,46 @@ describe("event media control helpers", () => { () => sanitizeEventMediaCommandInput({ type: "force_direct_link_fallback" }), /Direct-link fallback requires at least one public fallback link\./, ); + + assert.throws( + () => sanitizeEventMediaCommandInput({ type: "publish_fallback_link" }), + /Direct-link fallback requires at least one public fallback link\./, + ); + }); + + it("normalizes the canonical manual operator command vocabulary", () => { + assert.deepEqual(sanitizeEventMediaCommandInput({ type: "switch_next" }), { + type: "switch_next", + publicFallbackLinks: [], + }); + assert.deepEqual(sanitizeEventMediaCommandInput({ type: "switch_previous" }), { + type: "switch_previous", + publicFallbackLinks: [], + }); + assert.deepEqual(sanitizeEventMediaCommandInput({ type: "hold_current" }), { + type: "hold_current", + publicFallbackLinks: [], + }); + assert.equal( + sanitizeEventMediaCommandInput({ type: "preview_source", targetSourceKey: " DJ_Aurora " }).targetSourceKey, + "dj_aurora", + ); + assert.equal( + sanitizeEventMediaCommandInput({ type: "show_hold_scene", targetSceneKey: " Hold_Slate " }).targetSceneKey, + "hold_slate", + ); + }); + + it("requires source or scene targets for target-specific manual commands", () => { + assert.throws( + () => sanitizeEventMediaCommandInput({ type: "preview_source" }), + /preview_source requires a target source key\./, + ); + + assert.throws( + () => sanitizeEventMediaCommandInput({ type: "show_hold_scene" }), + /show_hold_scene requires a target scene key\./, + ); }); it("keeps non-VRCDN public links HTTPS-only", () => {