Skip to content

merge: upstream v2.25.0 (87 commits - self-hosting connector, responsive UI, media hardening) - #67

Merged
JOY (JOY) merged 89 commits into
devfrom
upstream-sync-v2.25.0
Oct 2, 2026
Merged

JOY (JOY) merged 89 commits into
devfrom
upstream-sync-v2.25.0

Conversation

@JOY

@JOY JOY (JOY) commented Oct 2, 2026 •

Copy link
Copy Markdown

Upstream sync 374fb20..8e42f09 (= tag v2.25.0), 87 commits since upstream-sync-20260926 (PR #65).

Features: self-hosting connector (relay a self-hosted Postiz to Postiz Cloud MCP), responsive UI pass, channel group rename from menu, Canva intent, Instagram custom thumbnail as reel cover, MCP video cover attachments.

Fixes: media pipeline hardening (SSRF-safe reads via axios client, stalled-read 2min timeout + fail instead of repeating publish, HTTP Range honored/clamped, batch upload failure isolation, upload-step error surfacing), TikTok publish/analytics id resolution, Facebook temporary posting-rate retry (368/1390008), Telegram no longer logs post text, Sentry pinned 10.56.0 (span/scope leak), editor no longer wipes in-flight uploads, web3/custom-fields/extension connect flows stop on 402.

Conflicts resolved:

  • .env.example + CLAUDE.md: union (Crove blocks + upstream additions).
  • pnpm-lock.yaml: upstream side regenerated against the merged package.json.
  • main.billing.component: keep Crove's showPortalAndCancel plan gating, add upstream's responsive button classes.
  • oauth.controller: union imports (self-hosted endpoint plumbing, inert without env).
  • onboarding.modal / public.component / oauth/authorize/page: took the CROVE side wholesale - these are the brand-gated upstream-shared UI surfaces (official connector directory URLs route users to the upstream cloud; the branding guard exists to keep them out). Upstream's self-hosted connector UI is a Postiz-Cloud branding surface and is intentionally not shipped here.

Schema (deploy gating): new OAuthSelfHostedAuthorization table + OAuthApp.organizationId index. Beta DB gets the idempotent DDL at deploy; the shared prod DB goes through the DOS-Me ledger before the prod deploy (rule 9). Migration SQL follows via the DOS-Me handoff.


📌 TL;DR

This PR introduces a self-hosted MCP relay authorization flow, adds HTTP Range request support to the upload API to fix video uploads, and implements extensive mobile responsiveness improvements across the dashboard, calendar, and developer tools.

🎯 Type of Change

  • 🚀 New feature
  • 🐛 Bugfix
  • 🧹 Refactor
  • ⚡ Performance
  • 📚 Documentation
  • ⚙️ CI / Configuration

🔍 Changes Walkthrough

File Summary of Changes
.env.example Added configuration variables for Apple Sign-In, Canva App Origin, OpenAI/ChatGPT OAuth, and MCP self-hosted relay settings.
apps/backend/src/api/routes/enterprise.controller.ts Minor formatting adjustment to JWT verification logic.
apps/backend/src/api/routes/integrations.controller.ts Added PUT /customers/:id endpoint to update customer names.
apps/backend/src/api/routes/oauth.controller.ts Implemented POST /authorize/self-hosted for MCP relay connections, added throttling, and fixed token endpoint status code to 200 for Canva compatibility.
apps/backend/src/main.ts Added CANVA_APP_ORIGIN to the CORS allowed origins list.
apps/backend/src/public-api/routes/v1/public.integrations.controller.ts Added GET /me endpoint to return organization and user details for OAuth app tokens.
apps/backend/src/services/auth/public.auth.middleware.ts Propagated userId from OAuth authorization to the request object for the new /me endpoint.
apps/frontend/src/app/(app)/api/uploads/[[...path]]/route.ts Implemented HTTP Range request handling (206 Partial Content) to support chunked video uploads for providers like TikTok and YouTube.
apps/frontend/src/app/global-error.tsx Simplified Sentry error reporting to avoid dependency on the variables context which is unavailable in the global error boundary.
apps/frontend/src/components/agents/agent.tsx Added mobile-specific layout classes to stack agent list and threads vertically on small screens.
apps/frontend/src/components/billing/main.billing.component.tsx Adjusted button and text alignment for mobile responsiveness.
apps/frontend/src/components/developer/developer.component.tsx Added mobile padding, flex-wrap, and text-break classes to OAuth application management UI.
apps/frontend/src/components/launches/add.provider.component.tsx Added handling for 402 Payment Required responses during integration connection and adjusted grid layout for mobile.
apps/frontend/src/components/launches/calendar.context.tsx Defaulted calendar view to "day" on mobile devices to prevent overflow.
apps/frontend/src/components/launches/calendar.tsx Added tablet/mobile grid column adjustments and shrink-0 classes to prevent layout collapse.
apps/frontend/src/components/launches/filters.tsx Made filter bar flex-wrap and adjusted widths for mobile screens.
apps/frontend/src/components/launches/helpers/date.picker.tsx Adjusted date picker positioning and text size for mobile.
apps/frontend/src/components/launches/launches.component.tsx Implemented a mobile bottom-sheet pattern for the channels sidebar and added a group name editing modal.
apps/frontend/src/components/launches/menu/menu.tsx Added logic to prevent context menus from overflowing the right edge of the viewport on small screens.
apps/frontend/src/components/layout/impersonate.tsx Adjusted impersonation banner layout for mobile devices.

📊 Architectural Flow

sequenceDiagram
    participant Client as MCP Client (e.g., Claude)
    participant Backend as Postiz Backend
    participant Relay as MCP Relay Service
    participant SelfHosted as Self-Hosted Instance

    Client->>Backend: POST /oauth/authorize/self-hosted
    Note right of Client: Includes instance_url, api_key, client_id
    Backend->>Backend: Validate Authorization Request
    Backend->>Relay: connect(instance_url, api_key)
    Relay->>SelfHosted: Verify Connection
    SelfHosted-->>Relay: Success
    Relay-->>Backend: Instance Metadata
    Backend->>Backend: Create Self-Hosted Authorization Code
    Backend-->>Client: Redirect with Code
    Client->>Backend: POST /oauth/token
    Note right of Client: Exchange Code for Token
    Backend-->>Client: Access Token (200 OK)
Loading

Gilad Resisi (giladresisi) and others added 30 commits July 6, 2026 19:01
When a token refresh fails, refreshProcess sent the refresh-error
notification (with the failure cause) and then called disconnectChannel,
which sends the same notification again without the cause. Every failed
refresh therefore emailed the user twice: once titled "Could not refresh
your <provider> channel <cause>" and once "Could not refresh your
<provider> channel".

Drop the disconnectChannel call from the failure branch: the
refreshNeeded call two lines earlier already sets the same flag
disconnectChannel would, so the only thing it added was the duplicate
email. disconnectChannel itself is unchanged for its other callers.

Reproduced by triggering a post on an Instagram channel holding an
invalid token (refresh fails since the provider cannot refresh) — two
emails arrived for the single failure. After the fix, the same scenario
produces exactly one email (the one including the failure cause), and
the channel is still flagged as needing reconnection.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
When a single-video reel's media has a thumbnail set, send it as
cover_url on the media container so Instagram uses it as the reel
cover; media without a thumbnail keep publishing via thumb_offset
exactly as before. Stories, carousel items and images are untouched.

E2e-tested on a Facebook-Login Instagram channel: reels published with
both JPEG and PNG covers, cover confirmed on the profile reels grid
(PNG accepted despite Meta docs listing JPEG only). Standalone
Instagram-Login shares this postPending, not separately e2e-tested.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
validatePosts runs settings-DTO validation only when the provider
declares `dto`. LinkedinProvider and MoltbookProvider never did, so for
linkedin, linkedin-page (inherits from LinkedinProvider) and moltbook
that validation layer silently did not run: LinkedinDto and MoltbookDto
existed but were unwired, and any malformed or missing settings value
was accepted and stored.

The dashboard never hits this (its form validates client-side with the
same DTO classes), but the public API (POST /public/v1/posts,
PUT /public/v1/posts/:id/settings) and the agent/MCP tools share
validatePosts and were unprotected - an API caller's typo or an
LLM-guessed settings value was accepted with a 200. The cost surfaced
later instead: a moltbook post without submolt silently publishes to
the "general" community (provider falls back to it), and a truthy
non-boolean carousel value on LinkedIn fails at publish time on a post
the user was told was valid.

Declare `dto = LinkedinDto` on LinkedinProvider (LinkedinPageProvider
inherits it) and `dto = MoltbookDto` on MoltbookProvider, so bad values
are rejected at save time with a specific message (400 / {errors}).
Audited all providers: these were the only unwired settings DTOs.
integrationSchedulePostTool attachments now accept either a plain URL
string or { path, thumbnail }, with thumbnail forwarded as the media
thumbnail (e.g. Instagram Reel cover_url). The public API already
accepted image[].thumbnail; this brings MCP to parity.

E2e-verified: MCP call with { path, thumbnail } and the equivalent
public-API request both published Reels with the custom cover.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…s work again

The local uploads route ignored the Range header and answered every request
with 200 and the whole file. TikTok, YouTube, LinkedIn and X now read video
chunks with ranged GETs and reject anything but 206, so every video upload
on STORAGE_PROVIDER=local failed with "did not return the requested byte
range". Serve 206 + Content-Range from a ranged createReadStream, advertise
Accept-Ranges, and return 416 for out-of-bounds ranges. Plain GETs are
unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…leak fix

The installed SDK (10.45.0) has a memory leak in @sentry/core: spans and
scopes reference each other circularly and cannot be garbage collected,
and the span exporter keeps sent spans in an unbounded map. Upstream
tracked it as "Memory leak with high tracesSampleRate"
(getsentry/sentry-javascript#18339) and fixed it in 10.56.0
(getsentry/sentry-javascript#21242). 10.56.0 also carries the 10.49.0
fix for contexts retained through scopes on pooled connections
(getsentry/sentry-javascript#20328).

This matches what we saw in production: the MCP service climbed to the
V8 heap limit about once a day while trace sampling was 100%, and the
climb stopped when gitroomhq#2082 cut sampling to 20%. Sampling less only slows
the leak; this fixes it at the source, for the backend, the orchestrator
and the frontend server alike.

Pinned exactly, not with a caret, on purpose. 10.56.0 is the first
release with the fix, and later minors change behaviour we have not
evaluated: 10.61.0 streams gen_ai spans by default and stops truncating
AI message data, 10.71.0 enables logs by default, 10.72.0 stops
reporting AI errors that propagate to the caller. The lockfile was
regenerated from the committed one with only these four specifiers
changed; the rest of the diff is Sentry's own dependency tree (several
OpenTelemetry instrumentations are now vendored and drop out).
@sentry/webpack-plugin keeps its own build-time @sentry/core copy.

Behaviour changes that do ride along (10.46.0 to 10.56.0): OpenAI span
attributes are renamed from openai.* to gen_ai.* (10.48.0), the NestJS
instrumentation is vendored inside the SDK (10.54.0), array attribute
values are sent as arrays (10.54.0). sendDefaultPii, which the frontend
init uses, is deprecated in 10.57.0 and is untouched here.

Testing on 10.56.0, against a Sentry development environment unless
noted:
- backend, orchestrator and frontend type-check; backend and
  orchestrator boot; production `next build` passes, also when the
  source-map upload fails (the next.config error handler absorbs it)
- backend: HTTP transactions, console logs, an unhandled controller 500
  through the Nest global filter with the organization tag, a process
  level crash, Sentry.metrics counters and profiler ids on spans
- frontend: client init with every configured integration, user and
  organization tag, an uncaught browser error linked to its session
  replay, the crash report dialog, the feedback form, pageload spans
  with browser profiles
- OpenAI integration against a local stub: the wrapped client works for
  chat.completions.create and .parse; create emits a gen_ai.chat span;
  .parse and images.generate are not instrumented on 10.45.0 either
- MCP flows (initialize, tools/list, tools/call) unchanged

Not tested: a successful source-map upload to the real project.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…creen

global-error.tsx only reported the error when `useVariables()` returned a
Sentry DSN. This component replaces the root layout, so the variables
context provider is never mounted around it and the context default
(an empty DSN) is what it always got. The effect returned early every
time: in a production build a React render crash showed "Application
error", sent no Sentry event and opened no report dialog.

It went unnoticed because development builds behave differently: there
React re-throws boundary-caught errors to the global error handler, so
the SDK's own handler reported them and the dialog appeared. Production
React does not re-throw, so this component is the only place such an
error can be reported from. Errors outside rendering (timers, promises,
event handlers) were never affected.

The DSN guard is removed rather than rewired. Without an initialised
client `captureException` is a no-op, so self-hosted installs without a
DSN behave as before. The explicit `showReportDialog` call is removed
too: it never ran, and the shared `beforeSend` already opens the report
dialog for every captured exception, so keeping it would request the
dialog twice.

Testing (production build, `next start`, headless Chrome, a temporary
client component that throws during render 3 seconds after load, Sentry
pointed at a local capture server):
- before: no event and no dialog request reach Sentry; the session
  replay is the only trace of the crash
- after, with a DSN: exactly one event (the render error, linked to its
  replay) and exactly one report dialog request
- after, without a DSN: nothing is sent, no dialog request, no extra
  JavaScript errors
- the before state was observed on SDK 10.56.0 and the fix verified on
  10.45.0 (this branch); the cause is in our component, not the SDK

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
afterRequest consumed the body to show the dialog message, so callers threw 'body stream already read' on dismiss (154 events/30d on /launches in prod).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017g3KqiuR5TT68XdqpTRbZh
Facebook Page videos were uploaded with the post text as description only,
so they always published without a title, and the MCP/public API settings
schema for Facebook offered no way to set one.

FacebookDto gets an optional title string. The provider adds it to the
POST /{page-id}/videos body only when it is non-empty and the post is a
feed post whose first attachment is an mp4. Stories, photo posts, text
posts and background presets are untouched, and posts without a title
send exactly the same request as before. The editor's Facebook settings
show a title input for posts and hide it for stories.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Return early on a 402 instead of following the billing url in its body.
…ws on a 402

Same early return as the regular flow, so none of them use the billing url from the 402 body.
…2-body-read-main

fix(frontend): keep the 402 response readable and stay on Add Channel after cancel
Check the POST /posts response; on failure keep the editor open and show the server message (402 stays silent, the payment dialog already explained it).
… the uppy progress error

gitroomhq#2147 ignored the Safari wording of the @uppy/aws-s3 4.3.2 progress
callback crash ("undefined is not an object (evaluating 'r.progress')"),
but Chrome and Edge report the same throw as "Cannot read properties of
undefined (reading 'progress')", which the pattern does not match, so
those users still get the "Something broke" report dialog.

The throw is in the plugin's own onProgress (getFile(id).progress on a
file that is no longer in state), fired from the part request's load
handler after our error handler cancels the remaining files. gitroomhq#2145
guarded our upload-success handler, not this plugin line.

Sentry CLOUD-20X, 1054 events / 33 users since 2026-09-16.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…cess-on-error

fix(frontend): don't report a failed post save as successful
Per RFC 7233 section 2.1, a last-byte-pos at or past the file length
means the rest of the file. Clamp it and serve 206; keep 416 only for
a start at or past the file size, or a start after the end.

Tested on local storage against a 22MB video: before this change,
bytes=16777216-25165823 (final 8MB chunk overshooting the file),
bytes=22041989-99999999 and bytes=0-22041990 all returned 416. After
it, all three return 206 with Content-Range clamped to the last byte
and bodies byte-identical to the file on disk. In-bounds ranges still
return the same 206, a start past the file still returns 416, and a
plain GET still returns 200 with the full file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nge-main

fix(uploads): honor HTTP Range requests so local-storage video uploads work again
…py-progress-chrome

fix(frontend): stop the Sentry report dialog on the Chrome wording of the uppy progress error
The uploader's Uppy `error` handler called `cancelAll()` for every error.
Uppy core also emits `error` when a single file fails (from its
`upload-error` handler), so one failed file, e.g. a transient 5xx on
`/media/create-multipart-upload`, dropped every other file of the batch:
finished files were saved to the media library but never attached, the
rest were cancelled, and the user got no message. Cancelling the other
in-flight files is also what makes @uppy/aws-s3's progress callback throw
on a removed file (Sentry CLOUD-20X).

The handler now returns early while the upload is still in
`currentUploads` (a single file failed); core removes the upload before
emitting `error` when the whole upload fails, so that path still resets
the uploader. `complete` already fires with `successful` and `failed`: it
now also removes the failed files, so the next upload does not silently
retry them, and shows a warning toast when any file failed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…tch-on-file-error

fix(media): keep the rest of the upload batch when one file fails
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…-client

fix(frontend): remove duplicate 'use client' directive in VK provider
Hover pencil on the group header opens a small rename modal backed by a new PUT /integrations/customers/:id (org-scoped, duplicate name rejected).
…-main

feat(groups): rename a channel group from the channel menu
…h-error-email

fix(integrations): send a single notification when a token refresh fails
…ed-data-rules-main

docs(claude): add shared-query and persisted-data change rules
TikTok only exposes the public post id after moderation, so posts kept
the publish id as releaseId and the profile URL as releaseURL, and
per-post analytics returned []. Add an optional resolveReleaseId provider
hook that checkPostAnalytics calls before postAnalytics, persisting the
resolved id + permalink. Both TikTok providers implement it, match any
publish id prefix, and read the int64 id from the raw body so it is not
rounded by JSON.parse.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VcaeTzorDM71yKNf9h3Fo4
Legacy photo posts store p_pub_url~ ids, which the v_pub_ check skipped.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VcaeTzorDM71yKNf9h3Fo4
…edential-rows

docs(claude): add a rule on credentials and sensitive fields in responses
A stalled read never settles, so the activity holds its worker slot long
past startToCloseTimeout. Time both buffered reads out, retry once, and
let the workflow repeat the publish via post.workflow.v1.1.3.
readOrFetch took a user-influenced path on plain axios, unlike every
other media read in the class.
A stall does not clear within the workflow's retry window, so repeating
the publish only re-reads the same media and re-uploads what was already
sent while holding the provider's queue slot. Throw BadBody once the
read's own retry is spent, which drops the need for a new workflow
version.
The slowest healthy read measured was about eleven seconds, so a minute
was already generous, but a stalled post fails on this timeout and there
is no cost to being certain.
fix(media): bound media reads that stall after their headers
Returns the current releaseId and releaseURL so other callers can reuse it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ost-id-main

fix(tiktok): resolve and persist the public post id for analytics
Self-hosting connector (MCP relay for self-hosted installs), responsive
UI, channel group rename, Canva intent, Instagram reel cover thumbnail,
TikTok publish/analytics id fixes, media pipeline hardening (SSRF-safe
reads, stalled-read timeouts, Range requests, batch failure isolation),
Facebook posting-rate retry, Sentry 10.56.0 span/scope leak pin.

Conflicts resolved:
- .env.example / CLAUDE.md: union (Crove blocks + upstream additions)
- pnpm-lock.yaml: upstream side, regenerated against merged package.json
- main.billing.component: keep showPortalAndCancel gating (Crove) and
  add upstream's responsive button classes
- oauth.controller: union imports for the self-hosted endpoints (backend
  plumbing, inert without env)
- onboarding.modal / public.component / oauth authorize page: took the
  Crove side wholesale - these are the brand-gated upstream-shared UI
  surfaces the branding guard protects (official connector directory
  URLs route users to the upstream cloud)

Schema: new OAuthSelfHostedAuthorization table + OAuthApp organizationId
index. Beta DB gets the idempotent DDL at deploy; the shared prod DB
goes through the DOS-Me ledger before the prod deploy (rule 9).
Comment on lines +261 to +270
await this.getSsrfSafeAxios()({
url: path,
method: 'GET',
responseType: 'arraybuffer',
// Same stall as the ranged reads: headers arrive fast and the body
// then stops, and without a deadline the read never settles and
// holds the activity's worker slot. Images are a few MB and arrive
// in about a second.
signal: AbortSignal.timeout(MEDIA_READ_TIMEOUT),
})
Comment on lines +127 to +143
const save = useCallback(async () => {
const response = await fetch(`/integrations/customers/${id}`, {
method: 'PUT',
body: JSON.stringify({ name: groupName }),
});
if (!response.ok) {
const { message } = await response.json().catch(() => ({} as any));
toaster.show(
(Array.isArray(message) ? message[0] : message) ||
t('could_not_save_group', 'Could not save the group.'),
'warning'
);
return;
}
resolve(groupName);
close();
}, [groupName, id]);
Comment on lines +127 to +143
const save = useCallback(async () => {
const response = await fetch(`/integrations/customers/${id}`, {
method: 'PUT',
body: JSON.stringify({ name: groupName }),
});
if (!response.ok) {
const { message } = await response.json().catch(() => ({} as any));
toaster.show(
(Array.isArray(message) ? message[0] : message) ||
t('could_not_save_group', 'Could not save the group.'),
'warning'
);
return;
}
resolve(groupName);
close();
}, [groupName, id]);
Comment on lines +127 to +143
const save = useCallback(async () => {
const response = await fetch(`/integrations/customers/${id}`, {
method: 'PUT',
body: JSON.stringify({ name: groupName }),
});
if (!response.ok) {
const { message } = await response.json().catch(() => ({} as any));
toaster.show(
(Array.isArray(message) ? message[0] : message) ||
t('could_not_save_group', 'Could not save the group.'),
'warning'
);
return;
}
resolve(groupName);
close();
}, [groupName, id]);
Comment on lines +127 to +143
const save = useCallback(async () => {
const response = await fetch(`/integrations/customers/${id}`, {
method: 'PUT',
body: JSON.stringify({ name: groupName }),
});
if (!response.ok) {
const { message } = await response.json().catch(() => ({} as any));
toaster.show(
(Array.isArray(message) ? message[0] : message) ||
t('could_not_save_group', 'Could not save the group.'),
'warning'
);
return;
}
resolve(groupName);
close();
}, [groupName, id]);
Comment on lines +127 to +143
const save = useCallback(async () => {
const response = await fetch(`/integrations/customers/${id}`, {
method: 'PUT',
body: JSON.stringify({ name: groupName }),
});
if (!response.ok) {
const { message } = await response.json().catch(() => ({} as any));
toaster.show(
(Array.isArray(message) ? message[0] : message) ||
t('could_not_save_group', 'Could not save the group.'),
'warning'
);
return;
}
resolve(groupName);
close();
}, [groupName, id]);
setSubmitting(false);
}
},
[clientId, state, redirectUri, codeChallenge, codeChallengeMethod]
Comment on lines +64 to +72
}, [
clientId,
responseType,
state,
redirectUri,
codeChallenge,
codeChallengeMethod,
resource,
]);
Comment on lines +712 to +720
<img
src={
mode === 'dark'
? '/no-channels.svg'
: '/no-channels-colors.svg'
}
alt="No channels"
className="mx-auto min-w-[100%]"
/>
await mutate();
toaster.show(t('group_updated', 'Group Updated'), 'success');
},
[group.name, group.id, mutate, modals, t]
}
resolve(groupName);
close();
}, [groupName, id]);

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces support for connecting and relaying Model Context Protocol (MCP) and OAuth requests to self-hosted Postiz instances, alongside various mobile responsiveness and UI enhancements across the frontend. Key feedback includes addressing a potential hydration mismatch in the calendar context by avoiding dynamic window checks during render, replacing blocking synchronous file reads (readFileSync) with asynchronous alternatives (promises.readFile) in the social integrations abstract class, and adding optional chaining to prevent runtime errors when integrations are undefined during loading states.

Comment on lines +148 to 156
// A 7-column week doesn't fit a phone, so default small screens (tailwind `mobile`) to the day view
const [displaySaved, setDisplaySaved] = useCookie(
'calendar-display',
typeof window !== 'undefined' &&
window.matchMedia('(max-width: 1025px)').matches
? 'day'
: 'week'
);
const display = searchParams.get('display') || displaySaved;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Initializing the cookie default value dynamically using window.matchMedia during render will cause a Hydration Mismatch in Next.js. The server-rendered HTML (which defaults to 'week' because window is undefined) will not match the client-rendered HTML on mobile devices (which defaults to 'day').

To fix this, initialize the cookie with a static default value (e.g., 'week'), and use a useEffect hook to dynamically switch to 'day' on mount if the user is on a mobile device and no cookie has been set yet.

Suggested change
// A 7-column week doesn't fit a phone, so default small screens (tailwind `mobile`) to the day view
const [displaySaved, setDisplaySaved] = useCookie(
'calendar-display',
typeof window !== 'undefined' &&
window.matchMedia('(max-width: 1025px)').matches
? 'day'
: 'week'
);
const display = searchParams.get('display') || displaySaved;
const [displaySaved, setDisplaySaved] = useCookie('calendar-display', 'week');
const display = searchParams.get('display') || displaySaved;
useEffect(() => {
if (window.matchMedia('(max-width: 1025px)').matches) {
const hasCookie = document.cookie.includes('calendar-display=');
if (!hasCookie) {
setDisplaySaved('day');
}
}
}, [setDisplaySaved]);

} from '@gitroom/nestjs-libraries/dtos/webhooks/ssrf.safe.dispatcher';
import sharp from 'sharp';
import { createReadStream, statSync } from 'fs';
import { createReadStream, readFileSync, statSync } from 'fs';

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Import promises from 'fs' instead of readFileSync to allow asynchronous, non-blocking file reads.

Suggested change
import { createReadStream, readFileSync, statSync } from 'fs';
import { createReadStream, promises, statSync } from 'fs';

// classify a stall like the ranged reads below do.
protected async readOrFetch(path: string, retried = false): Promise<Buffer> {
if (path.indexOf('http') !== 0) {
return readFileSync(path);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Use promises.readFile instead of readFileSync to prevent blocking the Node.js event loop when reading local media files.

Suggested change
return readFileSync(path);
return promises.readFile(path);

Comment on lines 90 to 92
const integration = integrations.find(
(i) => i.id === post.integration.id
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

If integrations is undefined or null (e.g., during the initial loading state), calling .find() on it will throw a runtime error. Use optional chaining integrations?.find to safely handle this case.

Suggested change
const integration = integrations.find(
(i) => i.id === post.integration.id
);
const integration = integrations?.find(
(i) => i.id === post.integration.id
);

@JOY

Copy link
Copy Markdown
Author

Reviewer disposition (verified finding re: Range handling in the uploads route): accepted-as-is, not patched.

The verified claim is that the Range regex does not match multi-range headers, so a multi-range request is served a full-file 200 instead of 206/416. That is RFC 7233-compliant behavior (a server MAY ignore Range and return 200; every mainstream upload client handles it), it is upstream's shipped-and-tested v2.25.0 code, and patching upstream logic in a sync PR is against the standing no-shared-rebuilds directive. If a real client ever breaks on this, a targeted follow-up patch (take first range, 206) is the documented remediation.

The two security claims (authorizeSelfHosted public endpoint, public.auth.middleware userId) were verified-dismissed by the critic - no authorization bypass; apiKey validation happens in McpRelayService and userId is signature-bound.

The branding guard blocked the two 'Postiz MCP' server names that came
with the self-hosting connector feature; they render in agent connector
UIs, so they take the deployment brand like everything else.
@JOY
JOY (JOY) merged commit 1a302e3 into dev Oct 2, 2026
8 of 11 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants