diff --git a/index.html b/index.html index a3ec45f..8dd19b9 100644 --- a/index.html +++ b/index.html @@ -13,21 +13,21 @@ + content="Mycelial experimentation in the digital realm."> - + + content="Mycelial knowledge and economic experimentation in the digital realm."> - + + content="Mycelial knowledge and economic experimentation in the digital realm."> diff --git a/src/hooks/useCameraControls.ts b/src/hooks/useCameraControls.ts index 8fbb1ea..98cd87b 100644 --- a/src/hooks/useCameraControls.ts +++ b/src/hooks/useCameraControls.ts @@ -64,9 +64,9 @@ export function useCameraControls(editor: Editor | null) { if (!editor) return const camera = editor.getCamera() const url = new URL(window.location.href) - url.searchParams.set("x", Math.round(camera.x).toString()) - url.searchParams.set("y", Math.round(camera.y).toString()) - url.searchParams.set("zoom", Math.round(camera.z).toString()) + url.searchParams.set("x", camera.x.toFixed(2)) + url.searchParams.set("y", camera.y.toFixed(2)) + url.searchParams.set("zoom", camera.z.toFixed(2)) navigator.clipboard.writeText(url.toString()) }, diff --git a/src/routes/Board.tsx b/src/routes/Board.tsx index d67f9a9..c893f5b 100644 --- a/src/routes/Board.tsx +++ b/src/routes/Board.tsx @@ -33,9 +33,10 @@ import { llm } from "@/utils/llmUtils" import { lockElement, unlockElement, - setInitialCameraFromUrl, + //setInitialCameraFromUrl, initLockIndicators, watchForLockedShapes, + zoomToSelection, } from "@/ui/cameraUtils" // Default to production URL if env var isn't available @@ -77,6 +78,8 @@ export function Board() { const store = useSync(storeConfig) const [editor, setEditor] = useState(null) + const [isCameraLocked, setIsCameraLocked] = useState(false) + useEffect(() => { const value = localStorage.getItem("makereal_settings_2") if (value) { @@ -97,6 +100,71 @@ export function Board() { watchForLockedShapes(editor) }, [editor]) + useEffect(() => { + if (!editor) return + + // First set the camera position + const url = new URL(window.location.href) + const x = url.searchParams.get("x") + const y = url.searchParams.get("y") + const zoom = url.searchParams.get("zoom") + const shapeId = url.searchParams.get("shapeId") + const frameId = url.searchParams.get("frameId") + const isLocked = url.searchParams.get("isLocked") === "true" + + const initializeCamera = async () => { + // Start with camera unlocked + setIsCameraLocked(false) + + if (x && y && zoom) { + editor.stopCameraAnimation() + + // Set camera position immediately when editor is available + editor.setCamera( + { + x: parseFloat(parseFloat(x).toFixed(2)), + y: parseFloat(parseFloat(y).toFixed(2)), + z: parseFloat(parseFloat(zoom).toFixed(2)) + }, + { animation: { duration: 0 } } + ) + + // Ensure camera update is applied + editor.updateInstanceState({ ...editor.getInstanceState() }) + } + + // Handle shape/frame selection after camera position is set + if (shapeId) { + editor.select(shapeId as TLShapeId) + const bounds = editor.getSelectionPageBounds() + if (bounds && !x && !y && !zoom) { + zoomToSelection(editor) + } + } else if (frameId) { + editor.select(frameId as TLShapeId) + const frame = editor.getShape(frameId as TLShapeId) + if (frame && !x && !y && !zoom) { + const bounds = editor.getShapePageBounds(frame) + if (bounds) { + editor.zoomToBounds(bounds, { + targetZoom: 1, + animation: { duration: 0 }, + }) + } + } + } + + // Lock camera after all initialization is complete + if (isLocked) { + requestAnimationFrame(() => { + setIsCameraLocked(true) + }) + } + } + + initializeCamera() + }, [editor]) + return (
Jeff Emmett

Hello! 👋🍄

- My research investigates the intersection of mycelium and emancipatory + My research investigates the intersection of mycelial patterns and emancipatory technologies. I am interested in the potential of new convivial tooling as a medium for group consensus building and collective action, in order - to empower communities of practice to address their own challenges. + to empower communities of practice to address their local challenges in an + age of ecological and instititutional collapse.

- My current focus is basic research into the nature of digital - organisation, developing prototype toolkits to improve shared - infrastructure, and applying this research to the design of new systems - and protocols which support the self-organisation of knowledge and - emergent response to local needs. + I let my curiosity about mushrooms guide me, taking inspiration from their + willingness to playfully experiment and adapt, even in the most chaotic environments. + I am fascinated by the potential of mycelial networks to create new forms of bottoms-up + sensing, collective cohereing around sensible directions, and emergent dynamic action + towards addressing local challenges.

My work

- Alongside my independent work, I am a researcher and engineering - communicator at Block Science, an - advisor to the Active Inference Lab, Commons Stack, and the Trusted - Seed. I am also an occasional collaborator with{" "} - ECSA. + I am fortunate enough to collaborate with some pretty incredible groups of + researchers and builders. I am a research communicator at + Block Science, an + advisor to the Active Inference Lab, + co-founder of Commons Stack, and + board member of the Trusted Seed. I am also + a collaborator with The Economic Space Agency.

-

Get in touch

+

Get in Touch to Collaborate

- I am on Twitter @jeffemmett - , Mastodon{" "} - @jeffemmett@social.coop{" "} + I am on Substack @All Things Decent, + Bluesky @jeffemmett, + Twitter @jeffemmett, + Mastodon@jeffemmett@social.coop and GitHub @Jeff-Emmett.

@@ -42,34 +46,29 @@ export function Default() {
  • MycoPunk Futures on Team Human with Douglas Rushkoff - {" "} - (slides) +
  • Exploring MycoFi on the Greenpill Network with Kevin Owocki - {" "} - (slides) +
  • Re-imagining Human Value on the Telos Podcast with Rieki & - Brandonfrom SEEDS - {" "} - (slides) + Brandon from SEEDS +
  • Move Slow & Fix Things: Design Patterns from Nature - {" "} - (slides) +
  • Localized Democracy and Public Goods with Token Engineering on the Ownership Economy - {" "} - (slides) +
  • diff --git a/src/shapes/VideoChatShapeUtil.tsx b/src/shapes/VideoChatShapeUtil.tsx index ba8385d..0f0e916 100644 --- a/src/shapes/VideoChatShapeUtil.tsx +++ b/src/shapes/VideoChatShapeUtil.tsx @@ -5,8 +5,11 @@ interface DailyApiResponse { url: string; } -interface DailyRecordingResponse { +interface DailyTranscriptResponse { id: string; + transcriptionId: string; + text?: string; + link?: string; } export type IVideoChatShape = TLBaseShape< @@ -17,8 +20,9 @@ export type IVideoChatShape = TLBaseShape< roomUrl: string | null allowCamera: boolean allowMicrophone: boolean - enableRecording: boolean - recordingId: string | null // Track active recording + enableTranscription: boolean + transcriptionId: string | null + isTranscribing: boolean } > @@ -36,8 +40,9 @@ export class VideoChatShape extends BaseBoxShapeUtil { h: 600, allowCamera: false, allowMicrophone: false, - enableRecording: true, - recordingId: null + enableTranscription: true, + transcriptionId: null, + isTranscribing: false } } @@ -145,80 +150,196 @@ export class VideoChatShape extends BaseBoxShapeUtil { } } - async startRecording(shape: IVideoChatShape) { + async startTranscription(shape: IVideoChatShape) { if (!shape.props.roomUrl) return; const workerUrl = import.meta.env.VITE_TLDRAW_WORKER_URL; const apiKey = import.meta.env.VITE_DAILY_API_KEY; + if (!apiKey) { + throw new Error('Daily.co API key not configured'); + } + try { - const response = await fetch(`${workerUrl}/daily/recordings/start`, { + // Extract room name from the room URL + const roomName = new URL(shape.props.roomUrl).pathname.split('/').pop(); + + const response = await fetch(`${workerUrl}/daily/rooms/${roomName}/start-transcription`, { method: 'POST', headers: { 'Authorization': `Bearer ${apiKey}`, 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - room_name: shape.id, - layout: { - preset: "active-speaker" - } - }) + } }); - if (!response.ok) throw new Error('Failed to start recording'); + if (!response.ok) { + const error = await response.json(); + throw new Error(`Failed to start transcription: ${JSON.stringify(error)}`); + } - const data = await response.json() as DailyRecordingResponse; + const data = await response.json() as DailyTranscriptResponse; await this.editor.updateShape({ id: shape.id, type: shape.type, props: { ...shape.props, - recordingId: data.id + transcriptionId: data.transcriptionId || data.id, + isTranscribing: true } }); } catch (error) { - console.error('Error starting recording:', error); + console.error('Error starting transcription:', error); throw error; } } - async stopRecording(shape: IVideoChatShape) { - if (!shape.props.recordingId) return; + async stopTranscription(shape: IVideoChatShape) { + if (!shape.props.roomUrl) return; const workerUrl = import.meta.env.VITE_TLDRAW_WORKER_URL; const apiKey = import.meta.env.VITE_DAILY_API_KEY; + if (!apiKey) { + throw new Error('Daily.co API key not configured'); + } + try { - await fetch(`${workerUrl}/daily/recordings/${shape.props.recordingId}/stop`, { + // Extract room name from the room URL + const roomName = new URL(shape.props.roomUrl).pathname.split('/').pop(); + + const response = await fetch(`${workerUrl}/daily/rooms/${roomName}/stop-transcription`, { method: 'POST', headers: { - 'Authorization': `Bearer ${apiKey}` + 'Authorization': `Bearer ${apiKey}`, + 'Content-Type': 'application/json' } }); + if (!response.ok) { + const error = await response.json(); + throw new Error(`Failed to stop transcription: ${JSON.stringify(error)}`); + } + + const data = await response.json() as DailyTranscriptResponse; + console.log('Stop transcription response:', data); + + // Update both transcriptionId and isTranscribing state await this.editor.updateShape({ id: shape.id, type: shape.type, props: { ...shape.props, - recordingId: null + transcriptionId: data.transcriptionId || data.id || 'completed', + isTranscribing: false } }); } catch (error) { - console.error('Error stopping recording:', error); + console.error('Error stopping transcription:', error); throw error; } } + async getTranscriptionText(transcriptId: string): Promise { + const workerUrl = import.meta.env.VITE_TLDRAW_WORKER_URL; + const apiKey = import.meta.env.VITE_DAILY_API_KEY; + + if (!apiKey) { + throw new Error('Daily.co API key not configured'); + } + + console.log('Fetching transcript for ID:', transcriptId); // Debug log + + const response = await fetch(`${workerUrl}/transcript/${transcriptId}`, { // Remove 'daily' from path + headers: { + 'Authorization': `Bearer ${apiKey}`, + 'Content-Type': 'application/json' + } + }); + + if (!response.ok) { + const error = await response.json(); + console.error('Transcript API response:', error); // Debug log + throw new Error(`Failed to get transcription: ${JSON.stringify(error)}`); + } + + const data = await response.json() as DailyTranscriptResponse; + console.log('Transcript data received:', data); // Debug log + return data.text || 'No transcription available'; + } + + async getTranscriptAccessLink(transcriptId: string): Promise { + const workerUrl = import.meta.env.VITE_TLDRAW_WORKER_URL; + const apiKey = import.meta.env.VITE_DAILY_API_KEY; + + if (!apiKey) { + throw new Error('Daily.co API key not configured'); + } + + console.log('Fetching transcript access link for ID:', transcriptId); // Debug log + + const response = await fetch(`${workerUrl}/transcript/${transcriptId}/access-link`, { // Remove 'daily' from path + headers: { + 'Authorization': `Bearer ${apiKey}`, + 'Content-Type': 'application/json' + } + }); + + if (!response.ok) { + const error = await response.json(); + console.error('Transcript link API response:', error); // Debug log + throw new Error(`Failed to get transcript access link: ${JSON.stringify(error)}`); + } + + const data = await response.json() as DailyTranscriptResponse; + console.log('Transcript link data received:', data); // Debug log + return data.link || 'No transcript link available'; + } + component(shape: IVideoChatShape) { const [hasPermissions, setHasPermissions] = useState(false) const [error, setError] = useState(null) const [isLoading, setIsLoading] = useState(true) const [roomUrl, setRoomUrl] = useState(shape.props.roomUrl) + const [isCallActive, setIsCallActive] = useState(false) + + const handleIframeMessage = (event: MessageEvent) => { + // Check if message is from Daily.co + if (!event.origin.includes('daily.co')) return; + + console.log('Daily message received:', event.data); + + // Check for call state updates + if (event.data?.action === 'daily-method-result') { + // Handle join success + if (event.data.method === 'join' && !event.data.error) { + console.log('Join successful - setting call as active'); + setIsCallActive(true); + } + } + + // Also check for participant events + if (event.data?.action === 'participant-joined') { + console.log('Participant joined - setting call as active'); + setIsCallActive(true); + } + + // Check for call ended + if (event.data?.action === 'left-meeting' || + event.data?.action === 'participant-left') { + console.log('Call ended - setting call as inactive'); + setIsCallActive(false); + } + }; + + useEffect(() => { + window.addEventListener('message', handleIframeMessage); + return () => { + window.removeEventListener('message', handleIframeMessage); + }; + }, []); useEffect(() => { let mounted = true; @@ -250,7 +371,7 @@ export class VideoChatShape extends BaseBoxShapeUtil { return () => { mounted = false; }; - }, [shape.id]); // Only re-run if shape.id changes + }, [shape.id]); useEffect(() => { let mounted = true; @@ -282,6 +403,28 @@ export class VideoChatShape extends BaseBoxShapeUtil { } }, [shape.props.allowCamera, shape.props.allowMicrophone]) + const handleTranscriptionClick = async (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + + if (!isCallActive) { + console.log('Cannot control transcription when call is not active'); + return; + } + + try { + if (shape.props.isTranscribing) { + console.log('Stopping transcription'); + await this.stopTranscription(shape); + } else { + console.log('Starting transcription'); + await this.startTranscription(shape); + } + } catch (err) { + console.error('Transcription error:', err); + } + }; + if (error) { return
    Error creating room: {error.message}
    } @@ -317,6 +460,16 @@ export class VideoChatShape extends BaseBoxShapeUtil { console.log(roomUrl) + // Debug log for render + console.log('Current call state:', { isCallActive, roomUrl }); + + // Add debug log before render + console.log('Rendering component with states:', { + isCallActive, + isTranscribing: shape.props.isTranscribing, + roomUrl + }); + return (
    { height: `${shape.props.h}px`, position: "relative", pointerEvents: "all", - overflow: "hidden", + overflow: "visible", }} > + allow="camera *; microphone *; display-capture *; clipboard-read; clipboard-write" + sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox allow-downloads allow-modals" + /> - {shape.props.enableRecording && ( - - )} + {/* Add data-testid to help debug iframe messages */} +
    + Call Active: {isCallActive ? 'Yes' : 'No'} +
    -

    - url: {roomUrl} -

    + + url: {roomUrl} + + +
  • ) } diff --git a/src/ui/CustomContextMenu.tsx b/src/ui/CustomContextMenu.tsx index f5bd02e..a30adc4 100644 --- a/src/ui/CustomContextMenu.tsx +++ b/src/ui/CustomContextMenu.tsx @@ -12,6 +12,7 @@ import { DefaultContextMenu, DefaultContextMenuContent } from "tldraw" import { TLUiContextMenuProps, useEditor } from "tldraw" import { cameraHistory, + copyLinkToLockedView, } from "./cameraUtils" import { useState, useEffect } from "react" import { saveToPdf } from "../utils/pdfUtils" @@ -95,11 +96,13 @@ export function CustomContextMenu(props: TLUiContextMenuProps) { + + {/* Creation Tools Group */} diff --git a/src/ui/cameraUtils.ts b/src/ui/cameraUtils.ts index 8086886..a6b35cd 100644 --- a/src/ui/cameraUtils.ts +++ b/src/ui/cameraUtils.ts @@ -81,9 +81,9 @@ export const zoomToSelection = (editor: Editor) => { const newCamera = editor.getCamera() const url = new URL(window.location.href) url.searchParams.set("shapeId", selectedIds[0].toString()) - url.searchParams.set("x", Math.round(newCamera.x).toString()) - url.searchParams.set("y", Math.round(newCamera.y).toString()) - url.searchParams.set("zoom", Math.round(newCamera.z).toString()) + url.searchParams.set("x", newCamera.x.toFixed(2)) + url.searchParams.set("y", newCamera.y.toFixed(2)) + url.searchParams.set("zoom", newCamera.z.toFixed(2)) window.history.replaceState(null, "", url.toString()) } @@ -119,41 +119,49 @@ export const revertCamera = (editor: Editor) => { } export const copyLinkToCurrentView = async (editor: Editor) => { - - if (!editor.store.serialize()) { - //console.warn("Store not ready") - return - } + if (!editor.store.serialize()) return try { const baseUrl = `${window.location.origin}${window.location.pathname}` const url = new URL(baseUrl) const camera = editor.getCamera() - // Round camera values to integers - url.searchParams.set("x", Math.round(camera.x).toString()) - url.searchParams.set("y", Math.round(camera.y).toString()) - url.searchParams.set("zoom", Math.round(camera.z).toString()) + // Round camera values to 2 decimal places + url.searchParams.set("x", camera.x.toFixed(2)) + url.searchParams.set("y", camera.y.toFixed(2)) + url.searchParams.set("zoom", camera.z.toFixed(2)) const selectedIds = editor.getSelectedShapeIds() if (selectedIds.length > 0) { url.searchParams.set("shapeId", selectedIds[0].toString()) } - const finalUrl = url.toString() + await navigator.clipboard.writeText(url.toString()) + } catch (error) { + alert("Failed to copy link. Please check clipboard permissions.") + } +} - if (navigator.clipboard && window.isSecureContext) { - await navigator.clipboard.writeText(finalUrl) - } else { - const textArea = document.createElement("textarea") - textArea.value = finalUrl - document.body.appendChild(textArea) - try { - await navigator.clipboard.writeText(textArea.value) - } catch (err) { - } - document.body.removeChild(textArea) +export const copyLinkToLockedView = async (editor: Editor) => { + if (!editor.store.serialize()) return + + try { + const baseUrl = `${window.location.origin}${window.location.pathname}` + const url = new URL(baseUrl) + const camera = editor.getCamera() + + // Round camera values to 2 decimal places + url.searchParams.set("x", camera.x.toFixed(2)) + url.searchParams.set("y", camera.y.toFixed(2)) + url.searchParams.set("zoom", camera.z.toFixed(2)) + url.searchParams.set("isLocked", "true") + + const selectedIds = editor.getSelectedShapeIds() + if (selectedIds.length > 0) { + url.searchParams.set("shapeId", selectedIds[0].toString()) } + + await navigator.clipboard.writeText(url.toString()) } catch (error) { alert("Failed to copy link. Please check clipboard permissions.") } @@ -283,47 +291,55 @@ export const initLockIndicators = (editor: Editor) => { }) } -export const setInitialCameraFromUrl = (editor: Editor) => { - const url = new URL(window.location.href) - const x = url.searchParams.get("x") - const y = url.searchParams.get("y") - const zoom = url.searchParams.get("zoom") - const shapeId = url.searchParams.get("shapeId") - const frameId = url.searchParams.get("frameId") +// export const setInitialCameraFromUrl = (editor: Editor) => { +// const url = new URL(window.location.href) +// const x = url.searchParams.get("x") +// const y = url.searchParams.get("y") +// const zoom = url.searchParams.get("zoom") +// const shapeId = url.searchParams.get("shapeId") +// const frameId = url.searchParams.get("frameId") +// const isLocked = url.searchParams.get("isLocked") === "true" - if (x && y && zoom) { - editor.stopCameraAnimation() - editor.setCamera( - { - x: Math.round(parseFloat(x)), - y: Math.round(parseFloat(y)), - z: Math.round(parseFloat(zoom)) - }, - { animation: { duration: 0 } } - ) - } +// // Always set camera position first if coordinates exist +// if (x && y && zoom) { +// editor.stopCameraAnimation() +// // Force camera position update +// editor.setCamera( +// { +// x: Math.round(parseFloat(x)), +// y: Math.round(parseFloat(y)), +// z: Math.round(parseFloat(zoom)) +// }, +// { animation: { duration: 0 } } +// ) + +// // Ensure camera update is applied +// editor.updateInstanceState({ ...editor.getInstanceState() }) +// } - // Handle shape/frame selection and zoom - if (shapeId) { - editor.select(shapeId as TLShapeId) - const bounds = editor.getSelectionPageBounds() - if (bounds && !x && !y && !zoom) { - zoomToSelection(editor) - } - } else if (frameId) { - editor.select(frameId as TLShapeId) - const frame = editor.getShape(frameId as TLShapeId) - if (frame && !x && !y && !zoom) { - const bounds = editor.getShapePageBounds(frame as TLShape) - if (bounds) { - editor.zoomToBounds(bounds, { - targetZoom: 1, - animation: { duration: 0 }, - }) - } - } - } -} +// // Handle other camera operations after position is set +// if (shapeId) { +// editor.select(shapeId as TLShapeId) +// const bounds = editor.getSelectionPageBounds() +// if (bounds && !x && !y && !zoom) { +// zoomToSelection(editor) +// } +// } else if (frameId) { +// editor.select(frameId as TLShapeId) +// const frame = editor.getShape(frameId as TLShapeId) +// if (frame && !x && !y && !zoom) { +// const bounds = editor.getShapePageBounds(frame as TLShape) +// if (bounds) { +// editor.zoomToBounds(bounds, { +// targetZoom: 1, +// animation: { duration: 0 }, +// }) +// } +// } +// } + +// return isLocked +// } export const zoomToFrame = (editor: Editor, frameId: string) => { if (!editor) return diff --git a/src/ui/overrides.tsx b/src/ui/overrides.tsx index 61ed742..284fad9 100644 --- a/src/ui/overrides.tsx +++ b/src/ui/overrides.tsx @@ -12,6 +12,7 @@ import { revertCamera, unlockElement, zoomToSelection, + copyLinkToLockedView, } from "./cameraUtils" import { saveToPdf } from "../utils/pdfUtils" import { searchText } from "../utils/searchUtils" @@ -348,6 +349,16 @@ export const overrides: TLUiOverrides = { } }, }, + //TODO: FIX COPY LOCKED LINK + copyLockedLink: { + id: "copy-locked-link", + label: "Copy Locked View Link", + kbd: "alt+shift+c", + onSelect() { + copyLinkToLockedView(editor) + }, + readonlyOk: true, + }, //TODO: FIX PREV & NEXT SLIDE KEYBOARD COMMANDS // "next-slide": { // id: "next-slide", diff --git a/src/utils/searchUtils.ts b/src/utils/searchUtils.ts index b20acbd..b87625e 100644 --- a/src/utils/searchUtils.ts +++ b/src/utils/searchUtils.ts @@ -77,9 +77,9 @@ export const searchText = (editor: Editor) => { const newCamera = editor.getCamera() const url = new URL(window.location.href) url.searchParams.set("shapeId", matchingShapes[0].id) - url.searchParams.set("x", newCamera.x.toString()) - url.searchParams.set("y", newCamera.y.toString()) - url.searchParams.set("zoom", newCamera.z.toString()) + url.searchParams.set("x", newCamera.x.toFixed(2)) + url.searchParams.set("y", newCamera.y.toFixed(2)) + url.searchParams.set("zoom", newCamera.z.toFixed(2)) window.history.replaceState(null, "", url.toString()) } else { alert("No matches found") diff --git a/worker/worker.ts b/worker/worker.ts index ac2d0f0..c1a2d16 100644 --- a/worker/worker.ts +++ b/worker/worker.ts @@ -9,13 +9,12 @@ export { TldrawDurableObject } from "./TldrawDurableObject" // Define security headers const securityHeaders = { "Content-Security-Policy": - "default-src 'self'; connect-src 'self' wss: https:; img-src 'self' data: blob: https:; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline';", + "default-src 'self'; connect-src 'self' wss: https:; img-src 'self' data: blob: https:; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; frame-src 'self' https://*.daily.co; child-src 'self' https://*.daily.co;", "X-Content-Type-Options": "nosniff", - "X-Frame-Options": "DENY", "X-XSS-Protection": "1; mode=block", "Strict-Transport-Security": "max-age=31536000; includeSubDomains", "Referrer-Policy": "strict-origin-when-cross-origin", - "Permissions-Policy": "camera=(), microphone=(), geolocation=()", + "Permissions-Policy": "camera=*, microphone=*, geolocation=()", } // we use itty-router (https://itty.dev/) to handle routing. in this example we turn on CORS because