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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .mcp.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"mcpServers": {
"playwright": {
"type": "stdio",
"command": "npx",
"args": ["-y", "@playwright/mcp@latest"],
"env": {}
}
}
}
5 changes: 2 additions & 3 deletions capacitor.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: '',
Expand Down
13 changes: 12 additions & 1 deletion ios/App/App/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,18 @@
<true/>
</dict>
<key>NSCameraUsageDescription</key>
<string>TimeHuddle uses the camera to update your profile photo.</string>
<string>TimeHuddle uses the camera to take photos and videos for messages and your profile photo.</string>
<!-- Required for the native HTTP bridge (CapacitorHttp) to reach a backend on
a private LAN address, which is how local development is served. Native
URLSession traffic to RFC1918 hosts is gated by iOS Local Network
Privacy; without this key iOS never prompts and every wormholeCall fails
with "unsatisfied (Local network prohibited)". NSAllowsLocalNetworking
above does NOT cover this — it is an ATS key governing HTTPS only.
Production builds talk to a public hostname, so no prompt is shown. -->
<key>NSLocalNetworkUsageDescription</key>
<string>TimeHuddle connects to a TimeHuddle server on your local network during development.</string>
<key>NSMicrophoneUsageDescription</key>
<string>TimeHuddle uses the microphone to record audio when capturing videos for messages.</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>TimeHuddle accesses your photo library to update your profile photo.</string>
<key>UIBackgroundModes</key>
Expand Down
44 changes: 24 additions & 20 deletions meteor-backend/server/huddle.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
}
Expand All @@ -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');
}
Expand All @@ -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) });
Expand All @@ -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 ?? [],
Expand All @@ -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');
}
Expand All @@ -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');
}
Expand Down Expand Up @@ -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');
}
Expand All @@ -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');
}

Expand Down
115 changes: 115 additions & 0 deletions meteor-backend/server/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
}
Expand Down Expand Up @@ -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',
Expand Down
Loading
Loading