diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 00000000..071c83e5 --- /dev/null +++ b/.mcp.json @@ -0,0 +1,10 @@ +{ + "mcpServers": { + "playwright": { + "type": "stdio", + "command": "npx", + "args": ["-y", "@playwright/mcp@latest"], + "env": {} + } + } +} diff --git a/capacitor.config.ts b/capacitor.config.ts index 71669dd4..dffa10b2 100644 --- a/capacitor.config.ts +++ b/capacitor.config.ts @@ -39,9 +39,8 @@ const config: CapacitorConfig = { App: {}, CapacitorUpdater: { - // Download in the background, swap bundles the next time the app is - // backgrounded. Disabled during live reload so Vite stays authoritative. - autoUpdate: liveReloadUrl ? 'off' : 'atBackground', + // OTA off during live reload and for local builds (no OTA backend). + autoUpdate: liveReloadUrl || otaChannel === 'local' ? 'off' : 'atBackground', updateUrl: otaUpdateUrl, // Self-hosted: no Capgo cloud, so no stats or channel endpoints. statsUrl: '', diff --git a/ios/App/App/Info.plist b/ios/App/App/Info.plist index ee61adfd..5e8c89b2 100644 --- a/ios/App/App/Info.plist +++ b/ios/App/App/Info.plist @@ -34,7 +34,18 @@ NSCameraUsageDescription - TimeHuddle uses the camera to update your profile photo. + TimeHuddle uses the camera to take photos and videos for messages and your profile photo. + + NSLocalNetworkUsageDescription + TimeHuddle connects to a TimeHuddle server on your local network during development. + NSMicrophoneUsageDescription + TimeHuddle uses the microphone to record audio when capturing videos for messages. NSPhotoLibraryUsageDescription TimeHuddle accesses your photo library to update your profile photo. UIBackgroundModes diff --git a/meteor-backend/server/huddle.js b/meteor-backend/server/huddle.js index 3a11b14d..06bf8842 100644 --- a/meteor-backend/server/huddle.js +++ b/meteor-backend/server/huddle.js @@ -228,19 +228,23 @@ Meteor.publish('huddlePosts.byTeam', async function (teamId) { // Methods Meteor.methods({ async 'huddle.getPosts'({ teamId }) { - if (!this.userId) { - throw new Meteor.Error('not-authorized', 'Authentication required'); - } + // requireIdentity, not this.userId: this is the REST feed refresh the + // composer runs right after creating a post (huddle.createPost is REST for + // the same reason — the WebView drops DDP while backgrounded). Over the + // wormhole bridge the caller is a bearer token and `this.userId` is always + // null, so a this.userId check rejected *every* REST refresh with + // "Authentication required". + const identity = await requireIdentity(this); if (!teamId || typeof teamId !== 'string') { throw new Meteor.Error('bad-request', 'teamId is required'); } - + const team = await getTeam(teamId); if (!team) { throw new Meteor.Error('not-found', 'Team not found'); } - - const isMember = (team.members ?? []).includes(this.userId) || (team.admins ?? []).includes(this.userId); + + const isMember = (team.members ?? []).includes(identity.userId) || (team.admins ?? []).includes(identity.userId); if (!isMember) { throw new Meteor.Error('forbidden', 'Not a team member'); } @@ -255,9 +259,11 @@ Meteor.methods({ }, async 'huddle.createPost'({ teamId, content, ticketId, attachments, postDate, draft, clockEventId, wrapUp }) { - if (!this.userId) { - throw new Meteor.Error('not-authorized', 'Authentication required'); - } + // requireIdentity: reachable via wormhole REST (bearer) and DDP alike. + // REST matters on mobile — WKWebView tears down the DDP socket whenever the + // app is backgrounded (e.g. to record a Pulse video), so a DDP-only write + // silently strands the post until the socket reconnects. + const identity = await requireIdentity(this); if (!teamId || typeof teamId !== 'string') { throw new Meteor.Error('bad-request', 'teamId is required'); } @@ -273,11 +279,11 @@ Meteor.methods({ throw new Meteor.Error('not-found', 'Team not found'); } - const isMember = (team.members ?? []).includes(this.userId) || (team.admins ?? []).includes(this.userId); + const isMember = (team.members ?? []).includes(identity.userId) || (team.admins ?? []).includes(identity.userId); if (!isMember) { throw new Meteor.Error('forbidden', 'Not a team member'); } - + // Validate ticketId if provided if (ticketId) { const ticket = await rawDb().collection('tickets').findOne({ _id: toId(ticketId) }); @@ -302,7 +308,7 @@ Meteor.methods({ const doc = { _id: new ObjectId(), teamId, - userId: this.userId, + userId: identity.userId, content: { text: content.text, mentions: content.mentions ?? [], @@ -328,9 +334,8 @@ Meteor.methods({ }, async 'huddle.updatePost'({ postId, content, wrapUp, attachments, ticketId }) { - if (!this.userId) { - throw new Meteor.Error('not-authorized', 'Authentication required'); - } + // requireIdentity: reachable via wormhole REST (bearer) and DDP alike. + const identity = await requireIdentity(this); if (!postId || !isValidId(postId)) { throw new Meteor.Error('bad-request', 'Invalid postId'); } @@ -348,7 +353,7 @@ Meteor.methods({ throw new Meteor.Error('not-found', 'Team not found'); } - const canModify = await canModifyPost(this.userId, post, team); + const canModify = await canModifyPost(identity.userId, post, team); if (!canModify) { throw new Meteor.Error('forbidden', 'Cannot modify this post'); } @@ -495,9 +500,8 @@ Meteor.methods({ * publication's change stream delivers it as an `added`). */ async 'huddle.publishPost'({ postId, content, postDate, clockEventId }) { - if (!this.userId) { - throw new Meteor.Error('not-authorized', 'Authentication required'); - } + // requireIdentity: reachable via wormhole REST (bearer) and DDP alike. + const identity = await requireIdentity(this); if (!postId || !isValidId(postId)) { throw new Meteor.Error('bad-request', 'Invalid postId'); } @@ -516,7 +520,7 @@ Meteor.methods({ throw new Meteor.Error('bad-request', 'Post is not a draft'); } // Drafts are strictly author-only — admins can't see or publish them. - if (post.userId !== this.userId) { + if (post.userId !== identity.userId) { throw new Meteor.Error('forbidden', 'Only the author can publish a draft'); } diff --git a/meteor-backend/server/main.js b/meteor-backend/server/main.js index 6cef44dc..18d7bf38 100644 --- a/meteor-backend/server/main.js +++ b/meteor-backend/server/main.js @@ -95,6 +95,24 @@ const _baseDomain = _rootHostname.includes('.') ? _rootHostname.split('.').slice(-3).join('.') // last 3 parts: os.mieweb.org : ''; +// RFC1918 private-LAN hostnames — dev live-reload serves the WebView from +// the machine's LAN IP (e.g. http://10.3.95.139:3000), which changes with +// DHCP/network and can't be pinned in ROOT_URL or CORS_ORIGINS. Without this, +// `_baseDomain` (derived from ROOT_URL) is a meaningless string for a dotted +// IPv4 host, so the origin never matches and CORS headers are skipped — +// breaking raw XHR/fetch calls from the WebView (e.g. tus-js-client video +// uploads) while native-bridge calls (CapacitorHttp) keep working, since +// those aren't subject to CORS. See memories/repo/timehuddle-capacitor-rest-cors.md. +function isPrivateLanHost(hostname) { + return ( + hostname === 'localhost' || + /^127\./.test(hostname) || + /^10\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(hostname) || + /^192\.168\.\d{1,3}\.\d{1,3}$/.test(hostname) || + /^172\.(1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3}$/.test(hostname) + ); +} + function isOriginAllowed(origin) { if (!origin) return false; if (CORS_ALLOW_ALL) return true; @@ -111,6 +129,8 @@ function isOriginAllowed(origin) { if (_baseDomain && _baseDomain !== 'localhost') { if (h === _baseDomain || h.endsWith('.' + _baseDomain)) return true; } + // Dev only: allow any private-LAN origin (see isPrivateLanHost above). + if (!Meteor.isProduction && isPrivateLanHost(h)) return true; } catch { /* ignore */ } return false; } @@ -1177,6 +1197,101 @@ Meteor.startup(async() => { }, }); + Wormhole.expose('huddle.getPosts', { + description: 'Fetch all published huddle posts for a team, newest first', + inputSchema: { + type: 'object', + properties: { teamId: { type: 'string' } }, + required: ['teamId'], + }, + }); + + // Post authoring over REST as well as DDP. On mobile the WebView drops the + // DDP socket whenever the app is backgrounded (recording a Pulse video, for + // one), so these writes must not depend on a live socket. + const huddlePostAttachmentSchema = { + type: 'array', + items: { + type: 'object', + properties: { + mediaId: { type: 'string' }, + type: { type: 'string', enum: ['image', 'video', 'file'] }, + url: { type: 'string' }, + thumbnailUrl: { type: 'string' }, + filename: { type: 'string' }, + }, + required: ['mediaId', 'type', 'url'], + }, + }; + + const huddlePostContentSchema = { + type: 'object', + properties: { + text: { type: 'string' }, + mentions: { type: 'array', items: { type: 'string' } }, + }, + required: ['text'], + }; + + Wormhole.expose('huddle.createPost', { + description: 'Create a huddle post (or an author-only draft)', + inputSchema: { + type: 'object', + properties: { + teamId: { type: 'string' }, + content: huddlePostContentSchema, + ticketId: { type: 'string' }, + attachments: huddlePostAttachmentSchema, + postDate: { type: 'string' }, + draft: { type: 'boolean' }, + clockEventId: { type: 'string' }, + wrapUp: { type: 'boolean' }, + }, + required: ['teamId', 'content'], + }, + outputSchema: { + type: 'object', + properties: { id: { type: 'string' } }, + }, + }); + + Wormhole.expose('huddle.updatePost', { + description: 'Update a huddle post (author, team admin, or org owner)', + inputSchema: { + type: 'object', + properties: { + postId: { type: 'string' }, + content: huddlePostContentSchema, + wrapUp: { type: 'boolean' }, + attachments: huddlePostAttachmentSchema, + ticketId: { type: ['string', 'null'] }, + }, + required: ['postId', 'content'], + }, + outputSchema: { + type: 'object', + properties: { id: { type: 'string' } }, + }, + }); + + Wormhole.expose('huddle.publishPost', { + description: 'Publish one of the caller\'s own drafts into the feed', + inputSchema: { + type: 'object', + properties: { + postId: { type: 'string' }, + postDate: { type: 'string' }, + content: huddlePostContentSchema, + clockEventId: { type: 'string' }, + }, + required: ['postId', 'postDate'], + }, + outputSchema: { + type: 'object', + properties: { id: { type: 'string' } }, + }, + }); + // ─── Timers ──────────────────────────────────────────────────────────────── Wormhole.expose('timers.getDay', { description: 'List WorkItems with timers for a local calendar day', diff --git a/meteor-backend/server/pulsevault.js b/meteor-backend/server/pulsevault.js index 074a5620..ed0458b1 100644 --- a/meteor-backend/server/pulsevault.js +++ b/meteor-backend/server/pulsevault.js @@ -57,6 +57,28 @@ const ISSUER = process.env.ROOT_URL; // retried create POST or a QR re-scan racing the original upload. const STALE_UPLOAD_IDLE_MS = 5 * 60 * 1000; +/** artifactId -> Set — active SSE subscribers waiting for upload-complete. */ +const sseClients = new Map(); + +function notifySseClients(artifactId, ready) { + const clients = sseClients.get(artifactId); + if (!clients?.size) return; + // Absolute on purpose, unlike stored URLs: SSE subscribers are off-device + // (the Pulse app that scanned the QR code), so they have no "current backend + // origin" to resolve a path against. + const payload = JSON.stringify({ + artifactId, + url: `${ISSUER}/pulsevault/artifacts/${artifactId}`, + size: ready?.size ?? 0, + }); + const event = `event: ready\ndata: ${payload}\n\n`; + for (const res of clients) { + try { res.write(event); } catch {} + try { res.end(); } catch {} + } + sseClients.delete(artifactId); +} + function lookupCapabilitySecret(kid) { return kid === CAPABILITY_KEY_ID ? CAPABILITY_SECRET : null; } @@ -128,20 +150,69 @@ Meteor.startup(async () => { console.log('[pulsevault] rehydrated', reservationContext.size, 'reservation(s) from Mongo'); }); +/** + * Playback path for an artifact — stored path-only, never host-qualified. + * + * ISSUER (ROOT_URL) is the address the backend answered on when the upload + * happened, which is not a property of the video: in dev the stack is served + * from the machine's LAN IP, so every DHCP lease change used to orphan every + * previously-uploaded clip. Clients re-attach their current backend origin at + * read time (`resolveMediaUrl` in src/lib/api.ts). + */ +function artifactPath(artifactId) { + return `/pulsevault/artifacts/${artifactId}`; +} + +/** + * Extension → MIME for every video container PulseVault accepts. + * + * Single source for three things that must agree: which extensions the tus + * create POST allows, the `Content-Type` the GET route serves, and the + * `mimeType`/`filename` recorded on the media item. + * + * `.mov`/`.m4v` are what the iOS camera roll and the native video picker hand + * back — they're ISO-BMFF like `.mp4`, so `createMp4Sniffer` accepts them. + */ +const VIDEO_CONTENT_TYPES = { + '.mp4': 'video/mp4', + '.mov': 'video/quicktime', + '.m4v': 'video/mp4', +}; + +/** Fallback when an artifact's stored extension can't be read back. */ +const DEFAULT_VIDEO_EXT = '.mp4'; + +/** + * The extension the bytes were actually stored under. + * + * Read from storage rather than assumed, so a `.mov` from an iPhone isn't + * recorded in the media library as an mp4 — which would both mislabel its + * `mimeType` and hand the user a `.mp4` filename for a QuickTime file. + * `getLocalPath` reads the sidecar, so this works before *and* after the + * artifact is marked ready (i.e. from both `onUploadComplete` and the + * orphaned-upload recovery path). + */ +async function artifactExt(artifactId) { + const localPath = await storage.getLocalPath(artifactId).catch(() => null); + const ext = localPath ? path.extname(localPath).toLowerCase() : ''; + return VIDEO_CONTENT_TYPES[ext] ? ext : DEFAULT_VIDEO_EXT; +} + /** Create the mediaitems doc / ticket attachment for a finished upload. */ async function attachUploadedVideo(artifactId, reservation, size = 0) { - const videoUrl = `${ISSUER}/pulsevault/artifacts/${artifactId}`; + const videoUrl = artifactPath(artifactId); const title = `Video ${artifactId.slice(0, 8)}`; + const ext = await artifactExt(artifactId); if (reservation.target === 'library') { await rawDb().collection('mediaitems').insertOne({ _id: new ObjectId(), userId: reservation.userId, type: 'video', - mimeType: 'video/mp4', + mimeType: VIDEO_CONTENT_TYPES[ext], url: videoUrl, videoid: artifactId, - filename: `${artifactId}.mp4`, + filename: `${artifactId}${ext}`, size, title, caption: null, @@ -162,7 +233,20 @@ async function attachUploadedVideo(artifactId, reservation, size = 0) { } } -const storage = createLocalStorage({ workspaceDir: VIDEOS_DIR }); +const localStorage_ = createLocalStorage({ workspaceDir: VIDEOS_DIR }); + +// The package's ext→MIME map only knows `.mp4`, so every other video container +// resolves to `application/octet-stream` — which `