Skip to content

release: DOS shared billing live, upstream sync 90 commits, SSO worker retirement - #41

Merged
JOY (JOY) merged 102 commits into
mainfrom
dev
Sep 19, 2026
Merged

JOY (JOY) merged 102 commits into
mainfrom
dev

Conversation

@JOY

Copy link
Copy Markdown

Promote dev to main

Deploy order

  1. DOS.Me#809 merged (schema columns on prod)
  2. This PR merged → new :latest image
  3. VM env + recreate crove-post

Gilad Resisi (giladresisi) and others added 30 commits July 2, 2026 19:26
… missing insights edge

postAnalytics always called /{postId}/insights, but that edge only exists on
page feed posts. Video/reel and story posts store a bare id whose node has no
insights edge, so Facebook returned "(#100) Tried accessing nonexisting field
(insights)" — surfaced in prod as "Error fetching Facebook post analytics:
ApplicationFailure: Unknown Error".

postAnalytics now branches on the stored releaseId shape (the only available
discriminator, since no post type is persisted for analytics):

- Feed post ({pageid}_{postid}, contains "_") -> unchanged: /{postId}/insights
  with the existing metrics/mapping. No regression.
- Video/reel (bare numeric id) -> /{videoId}/video_insights on v23.0, mapped to
  AnalyticsData: total_video_impressions -> Impressions, total_video_views ->
  Views, total_video_reactions_by_type_total -> Reactions (metric names verified
  against the current v23.0 docs).
- Story (bare id, no usable insights via this path) -> clean [], no error.

The video call uses plain fetch (not this.fetch) so a "(#100) nonexisting field
(video_insights)" / empty response yields a quiet [] instead of throwing. A
"nonexisting field" error stays silent (expected for stories); any other error
(bad metric name, token, permissions) is logged via console.warn so a silent
empty result is diagnosable without breaking the statistics page. postAnalytics
still never throws.

Tested locally against a freshly connected Facebook page: published a video post,
confirmed its releaseId resolves to a real video node, and that
/{videoId}/video_insights returns {"data":[]} (no data yet) with NO
"(#100) nonexisting field" or "must be one of" error — i.e. the old crash is gone,
the metric names are valid, and the app returns a clean [] for the no-data-yet case.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
… hijack the personal channel

Co-Authored-By: Claude Fable 5 <[email protected]>
…fetime_metrics

postAnalytics requests IMPRESSION, PIN_CLICK, OUTBOUND_CLICK and SAVE, but
read the results from lifetime_metrics. Pinterest returns period metrics in
summary_metrics; lifetime_metrics only ever carries lifetime metric types
(TOTAL_COMMENTS / TOTAL_REACTIONS), and a request cannot mix lifetime and
non-lifetime types. So lifetime_metrics was always empty and every Pinterest
pin showed no stats.

Verified against the live API: published a real pin, pre-fix the method
returned [], post-fix it returns all four metrics.

Co-Authored-By: Claude Fable 5 <[email protected]>
…image sizes

TikTok returns fail_reason `picture_size_check_failed` when media violates its
size rules, and we mapped it to "Video must be at least 720p, Picture must no
exceed 1080p". Both halves were wrong or unhelpful: TikTok documents no minimum
for photos, "1080p" never said which dimension, and the 720p video claim has no
basis in the docs.

A customer hit this on TikTok carousels created through the public API. Her
images were 941x1672 (valid), but each failing carousel also contained a
1086x1448 image - 6px over the 1080 limit on the shorter side - and a single
oversized image fails the entire carousel. TikTok never reports which image
failed, so the generic message sent her resizing images that were already fine.

Verified against the real API on a connected TikTok channel, uploading through
the public API so the media reaches TikTok at its original dimensions:

  480x640    -> PUBLISHED  (disproves the "at least 720p" claim)
  320x320    -> FAILED     (picture_size_check_failed, confirms the 360 floor)
  5000x5000  -> PUBLISHED  (TikTok does not enforce its documented 4096 max)

The message therefore claims only what was verified: the 1080 image ceiling and
the 360 video floor. The documented 4096 ceiling is omitted because TikTok
accepted a 5000x5000 video.

`checkValidity` now measures photos up front and names the offending image, so
the failure surfaces at save time instead of hours later at publish. It mirrors
the existing Pinterest check and reuses `getImageDimensions`. Videos are not
pre-checked: there is no ffmpeg dependency server-side, so video size still only
surfaces at publish through the corrected message.

Already-scheduled carousels are not re-validated - `checkValidity` only runs on
create/update - so existing invalid posts will still fail at publish, now with
the corrected message.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
…connection

A Facebook channel can end up holding the user's token instead of the
page's: if the page is only visible through a Business portfolio and was
never ticked in the OAuth page list, Graph returns no access_token for
it, fetchPageInformation passes undefined to the update, and Prisma
leaves the between-steps user token in place. The channel then looks
connected while every publish is rejected with "(gitroomhq#200) Unpublished posts
must be posted to a page as the page itself".

That error was unmapped, so it surfaced as a generic "Unknown Error" on
every post while the channel kept reporting as healthy - the user had no
way to tell a token problem from a content problem. Map it to
refresh-token so the channel is flagged, the reconnect notification is
sent, and the post is not retried against a token that can never work.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
…sage

Facebook rejects page publishes with an OAuthException code 200 when the
connected account lacks pages_manage_posts / pages_read_engagement or
sufficient page role. This previously fell through handleErrors to the
'Unknown Error' placeholder, so the failure email (and, once merged, the
curated calendar tooltip from gitroomhq#1868) showed nothing actionable.

Per Meta's docs, codes 200-299 are API Permission errors, so the (gitroomhq#200)
marker always denotes a permissions problem. As a side effect, the preset
retry helper no longer treats these as 'Unknown Error', avoiding a wasted
second publish attempt.

Co-Authored-By: Claude Fable 5 <[email protected]>
…ssage too

The same Graph (gitroomhq#200) OAuthException surfaces on the Instagram
(Facebook-login) provider when the connected user lacks sufficient
permissions on the Facebook Page linked to the Instagram account. Map it
the same way as in the Facebook provider (non-retryable bad-body), placed
before the generic '190,' rule; instagram-standalone delegates to the same
handleErrors.

Co-Authored-By: Claude Fable 5 <[email protected]>
The Reddit DTO advertised type as link/self/image/video/videogif, but the
provider only implements self, link and media (upload the first attached
file). Other values were forwarded to Reddit as kind without a file and
always failed with BAD_URL. Validate type with @isin and fix the public
API / MCP schema description.

Also initialise url to '' when a subreddit is picked in the composer, so
the URL input is controlled from mount (fixes the React uncontrolled to
controlled warning) and empty link URLs are validated.

Co-Authored-By: Claude Fable 5 <[email protected]>
…ling

After 5 failed preparation attempts the provider threw a generic
"Could not prepare the post for Bluesky" BadBody with an empty json
payload, swallowing the real reason (e.g. the video service rejecting
an oversized upload). Include the underlying error message in both the
BadBody json ({"message": ...}) and the user-facing failure message,
so the bell notification and failure email tell the user what actually
went wrong.

Co-Authored-By: Claude Fable 5 <[email protected]>
The subreddit picker stores Reddit's search result url verbatim
("/r/Name/"), and the sr normalization only removed the "/r/" prefix,
so submits were sent with a trailing slash ("name/"). Reddit accepts
that shape intermittently but sometimes rejects it with
SUBREDDIT_NOEXIST even though the community exists. Strip leading and
trailing slashes at both normalization sites.

Co-Authored-By: Claude Fable 5 <[email protected]>
Public-API users sometimes type "r/name" without the leading slash,
which the '/r/' string replace left untouched. Normalize both prefix
shapes with one regex so every input form resolves to the bare name.

Co-Authored-By: Claude Fable 5 <[email protected]>
Crove is first-party DOS, so checkout must grant the same user_plans row used by app.dos.ai.

Co-authored-by: Cursor <[email protected]>
Meta's Threads API counts the 500-character post limit in UTF-8 bytes (https://developers.facebook.com/docs/threads/posts), while Postiz counted UTF-16 code units, so a post with curly quotes, accented letters or emoji could pass the editor counter and the server validation and then be rejected by Threads at publish time. A thread whose second part was affected published only its first part.

Adds a countLength helper (X keeps twitter-text weighted length, Threads uses UTF-8 byte length, everything else plain length) and uses it in the /posts/valid check and in the editor character pill, so both sides flag the same text. The global-mode pill now shows the count for the channel whose limit it displays instead of the raw length.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01TEjfd79TJocNAFULswHkXV
Adds activeOrgsBySource to /admin/stats (distinct orgs with a QUEUE, PUBLISHED or ERROR post in the range, grouped by Post.creationMethod) and renders it as a summary card and table on the admin stats page.
…in state

Neynar retired Sign In With Neynar, so the hosted popup used for Farcaster
login and channel connect no longer completes. Replace it with Neynar managed
signers: the backend creates a signer, signs the EIP-712 signed key request
with the app custody mnemonic (viem), registers it with Neynar and renders the
approval deep link as a QR (qrcode) since the link is a bare 302 to the
farcaster:// scheme. The frontend polls the new public /auth/farcaster/signer
routes and builds the same base64 blob the popup produced, so authenticate(),
the auth provider, the stored token format and existing users/channels are
unchanged. @neynar/react is removed.

Also thread the oauth_state nonce through the Farcaster login button: since
the Apple login change, checkExists rejects logins without a matching state
cookie, which broke Farcaster login independently of the SIWN retirement.

New env vars: NEYNAR_APP_FID, NEYNAR_APP_MNEMONIC, NEYNAR_SPONSOR_SIGNERS.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_01R8gvBNgPqqayC9zGPB5R5a
A thrown fetch inside the async generator ended the for-await loop and
left the modal spinning with no timeout. Catch it and keep polling, as
the Moltbook status poll does; the 10 minute cap still applies.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_01R8gvBNgPqqayC9zGPB5R5a
POST /auth/farcaster/signer is unauthenticated and creates a signer at
Neynar on every call. The global throttler guard only covers the public
posts API, so add a route-level guard keyed by the forwarded client
address and cap the route at 10 requests per hour per address.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_01R8gvBNgPqqayC9zGPB5R5a
GET /auth/farcaster/signer is unauthenticated and calls Neynar on every
request. Apply the same per-address guard as the create route, with a
ceiling of 1000 per hour so the modal's 2s poll over a 10 minute window
still fits several approvals.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01R8gvBNgPqqayC9zGPB5R5a
…ed-signers

feat(farcaster): migrate from SIWN to Neynar managed signers, fix login state
After the managed-signer migration, clicking Farcaster on the login and
signup pages opened a new tab to the approval link, which only redirects
to the farcaster:// scheme and stays blank on desktop, while the QR was
squeezed into the narrow provider button slot.

The approval view now opens straight in a modal on the login and signup
pages and replaces the intermediate button step in the add/reconnect
channel modal. No tab opens; a Copy Farcaster link button replaces the
Open in Farcaster link. The auth layout mounts the shared modal manager
and the toaster, so errors on those pages are visible. A response
without an approval link (for example the rate limit) now shows the
failure toaster instead of leaving the modal empty, and the signer
creation limit is raised from 10 to 30 per hour per address.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01R8gvBNgPqqayC9zGPB5R5a
fix(farcaster): show the approval QR in a modal instead of a blank tab
…nsights

fix(facebook): video/reel analytics via video_insights (no more nonexisting-field crash)
fix: count Threads post length in UTF-8 bytes
…stats

feat(admin-stats): active orgs per post creation source
@mastra/pg checks for a column against a schema snapshot and then runs a
plain ALTER TABLE ADD COLUMN, so instances migrating at the same time race
and the loser throws 'column "organizationId" ... already exists' into the
first user request. Init now runs when the Mastra instance is created and
retries once against the already migrated schema.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
prisma-db-push runs with --accept-data-loss on deploy, so anything Mastra
created that the schema did not mirror (37 tables, 31 columns) was dropped
and rebuilt by @mastra/pg on the next boot, which is where instances raced
on ALTER TABLE ADD COLUMN. The mastra_* models are now introspected from a
database migrated by @mastra/pg 1.25, so db push creates them before the
backend starts and Mastra's init finds nothing to alter. Existing mirrored
columns are unchanged; migrate diff against a migrated DB is empty.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
The mastra_* tables are now mirrored in schema.prisma, so prisma db push
creates them before the backend starts and the init race cannot happen.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
…atGPT

MCP has no way to send a file from the user's device, so add an MCP Apps
(SEP-1865) widget: uploadWidgetTool renders ui://postiz/upload, the widget
gets a short-lived ticket through the app-only uploadWidgetTicketTool and
posts the file to /media-widget/upload, then reports the media back to the
model. uploadWidgetStatusTool is the host-independent fallback.

The widget tools are mcpOnly, so the in-app agent keeps its current toolset.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
feat(mcp): upload widget for uploading local files from Claude and ChatGPT
…rsonate

feat: superuser public API endpoints
The standalone OAuth bridge worker (sso.crove.com / beta-sso.crove.com)
was deprecated in favor of the central PKCE bridge embedded in
api.dos.me. Removes the worker app, its deploy workflow, lockfile
entries, and active-doc rows. Live traffic sampled at zero (60s tail)
before removal; login flow no longer references the worker.
…hatGPT app, OAuth hardening, provider fixes)

Conflict resolutions:
- .env.example: keep Crove rebranded reference (incl. DOS billing block)
- staging-conflicts.yml: keep deleted (upstream-only staging infra)
- wallet.provider.tsx: keep deleted (Solana dead-code removal, PR #34)
- copilot.controller: upstream NodeHttp endpoint + copilotCors + new
  CopilotKit request shape, plus our OpenAI-compatible env support
- oauth.controller: keep our revoke endpoint + their client_secret_basic
- auth layout: our branding/i18n + their MantineWrapper/Toaster
- load.tools.service: their mcpOnly filter + AgentToolInterface, our
  DOSClaw alias map and branded agent
- start.mcp: both (brand + MCP Apps upload widget)
- sentry: their tracesSampler/profiling quota fix + our bootstrap
  telemetry scrubbers
- schema.prisma: keep fork architecture (Mastra owns its tables; no
  prisma mirror)
- package.json/lockfile: 3-way semantic merge + regenerated lockfile
Review findings on PR #39:
- build.yml: drop unconditional 'Test SSO Worker (vitest)' step that
  would fail every push after apps/crove-sso is removed
- package.json: drop typecheck:sso / test:sso scripts
- deploy-beta.ps1 / deploy-prod.ps1: rewrite without SSO steps
  (Branding Guard + compose contract + container instructions only)
- build-all.ps1: drop SSO step, renumber to [1/3]..[3/3]
- scripts/deploy-sso.ps1: delete
- branding-guard.ts: drop dead apps/crove-sso/ strict prefix
- docs/cicd.md: update deploy script purposes, drop Deploy SSO row
- docs/README.md: drop crove-sso from repo tree
- .github/copilot-instructions.md: drop crove-sso directory entry
- docs/sso-integration.md: mark as historical design record, point to
  the api.dos.me PKCE bridge as the current implementation
…script

- build-all.ps1: remove stale LASTEXITCODE guard from the deleted SSO
  step (stale non-zero exit from a prior native command in the same
  PowerShell session would abort the build with 'SSO Test failed')
- dev.ps1: remove the dead -Mode sso branch and its ValidateSet entry
chore(sso): remove deprecated crove-sso Cloudflare Worker
…us fields

- load.tools.service: drop stray >>>>>>> marker left by resolution
- start.mcp: dedupe agents map (keep branded 3-alias version)
- schema.prisma: take upstream Media status/processingError fields
  (required by the MCP upload widget pipeline) while keeping the
  no-Mastra-tables fork architecture
chore(sync): merge upstream main (Mastra 1.67, CopilotKit 1.72, MCP ChatGPT app, provider fixes)
Comment on lines +135 to +138
response = await fetch(url, {
// @ts-ignore — undici option, not in lib.dom fetch types
dispatcher: ssrfSafeDispatcher,
});
window.location.href = `/auth?provider=FARCASTER&code=${code}`;
const state = await (await fetch('/auth/oauth/FARCASTER')).text();
window.location.href = `/auth?provider=FARCASTER&code=${encodeURIComponent(code)}&state=${state}`;
}, []);
t('link_copied_to_clipboard', 'Link copied to clipboard'),
'success'
);
}, [approvalUrl]);
return () => {
activeSigner.current = '';
};
}, []);
Comment on lines +185 to +189
<img
src={qrCode}
alt=""
className="w-[200px] h-[200px] rounded-[8px] bg-white"
/>
}
return subscription?.subscriptionTier;
}, [subscription, initialChannels, monthlyOrYearly, period]);
}, [subscription, initialChannels, monthlyOrYearly, period, sharedDosBilling]);

@dos dos 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.

⏱️ Adversarial Review completed (Model: qwen3.8-27b)

⚠️ Input diff exceeded 30000 chars and was truncated before review.

🔍 Verified Adversarial Review Findings

🟡 IMPORTANT

  • apps/backend/src/api/routes/media.controller.ts:167-178: Ghost file creation on successful multipart upload completion
    • Failure Trace:
      1. Client initiates a multipart upload to S3/R2 and completes it via POST /media/upload-server/complete-multipart-upload.
      2. handleR2Upload executes the S3 completeMultipartUpload API call.
      3. In many S3-compatible implementations (or if the library is configured to pipe the response), handleR2Upload may send the response headers/body (e.g., a 200 OK with XML or a 302 redirect) before returning. This sets res.headersSent = true.
      4. The condition if (endpoint !== 'complete-multipart-upload' || res.headersSent) evaluates to true because res.headersSent is true.
      5. The function returns upload immediately.
      6. The code block below (lines 167-178) is skipped. This block is responsible for calling this._mediaService.saveUploadedFile to persist the media record in the database.
      7. Result: The file exists in S3 storage but has no corresponding record in the database. The user cannot see or use the file in the UI, resulting in data loss/orphaned storage.
    • Actionable Fix:
      The logic should ensure that saveUploadedFile is called regardless of whether headers were sent, or the response sending should be deferred until after the database save. A safer pattern is to check if the response was successfully sent with a 2xx status, or to always attempt the save and only send the final JSON response if headers haven't been sent (logging a warning if they have). Alternatively, ensure handleR2Upload does not send the response for complete-multipart-upload and lets the controller handle the final response.

🛡️ Dismissed Claims

  • apps/backend/src/api/routes/media.widget.controller.ts:36-40 IDOR risk: The UploadWidgetAuthMiddleware is explicitly applied to MediaWidgetController in api.module.ts (consumer.apply(UploadWidgetAuthMiddleware).forRoutes(MediaWidgetController)). While the middleware implementation is not in the diff, the claim assumes it is insecure or missing. Without evidence that the middleware fails to bind the session to the user/org, this is a speculative concern about unprovided code. The controller correctly uses @GetOrgFromRequest() which relies on the authenticated context established by the middleware.
  • apps/backend/src/api/routes/auth.controller.ts:302-310 Missing permission guard: The endpoint is POST /auth/farcaster/signer. The AuthController is part of the authenticatedController list in api.module.ts, meaning it is protected by AuthMiddleware. The claim that it lacks authorization is partially true (no specific role check), but creating a signer is typically a user-level action, not an org-admin action. The throttling (limit: 30, ttl: 3600000) provides rate limiting. Without evidence that signer creation is a privileged operation requiring specific org permissions (e.g., billing or admin), this is a design choice rather than a clear security vulnerability. The primary risk (abuse) is mitigated by throttling.

@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 removes the Cloudflare Worker SSO bridge in favor of a centralized PKCE bridge via api.dos.me, integrates a media normalization pipeline using RunPod Serverless and Temporal workflows, and introduces an MCP upload widget toolset. It also adds DOS shared billing integration, improves Farcaster authentication via a QR code/link approval flow, and updates several social providers (Bluesky, Facebook, Instagram, Pinterest, Reddit, TikTok) to enhance error handling and feature support. Feedback on these changes highlights critical runtime issues, including stream type mismatches in the upload validation utility, Prisma compilation errors in the media repository due to non-unique fields in where clauses, potential TypeError crashes in the Facebook and Pinterest providers, and unhandled promise rejections in the TikTok provider.

Comment on lines +51 to +80
export async function uploadStreamToStorage(
storage: IUploadProvider,
webStream: ReadableStream,
declaredSize = 0
) {
let sniffed: ReadableStream & { fileType?: { mime: string; ext: string } };
try {
sniffed = await fileTypeStream(webStream);
} catch (err) {
throw new BadRequestException('Failed to read file', { cause: err });
}
const detected = sniffed.fileType;
if (!detected || !UPLOAD_ALLOWED_MIME.has(detected.mime)) {
await sniffed.cancel();
throw new BadRequestException('Unsupported file type.');
}

const maxSize = getMaxSize(detected.mime);
if (declaredSize > maxSize) {
await sniffed.cancel();
throw new BadRequestException('File is too large.');
}

// pipeline (not pipe) so failing the cap also tears down the source
const body = pipeline(
Readable.fromWeb(sniffed as any),
maxSizeStream(maxSize),
() => {}
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

critical

There are two stream type mismatches here that will cause runtime crashes:

  1. fileTypeStream expects a Node.js Readable stream, but is passed a web ReadableStream (webStream).
  2. sniffed returned by fileTypeStream is a Node.js Readable stream, but is passed to Readable.fromWeb (which expects a web ReadableStream).

To fix this, convert webStream to a Node.js stream first using Readable.fromWeb(webStream as any), pass that to fileTypeStream, and then pass the resulting sniffed stream directly to pipeline without wrapping it in Readable.fromWeb.

export async function uploadStreamToStorage(
  storage: IUploadProvider,
  webStream: ReadableStream,
  declaredSize = 0
) {
  const nodeStream = Readable.fromWeb(webStream as any);
  let sniffed: Readable & { fileType?: { mime: string; ext: string } };
  try {
    sniffed = await fileTypeStream(nodeStream);
  } catch (err) {
    throw new BadRequestException('Failed to read file', { cause: err });
  }
  const detected = sniffed.fileType;
  if (!detected || !UPLOAD_ALLOWED_MIME.has(detected.mime)) {
    sniffed.destroy();
    throw new BadRequestException('Unsupported file type.');
  }

  const maxSize = getMaxSize(detected.mime);
  if (declaredSize > maxSize) {
    sniffed.destroy();
    throw new BadRequestException('File is too large.');
  }

  // pipeline (not pipe) so failing the cap also tears down the source
  const body = pipeline(
    sniffed,
    maxSizeStream(maxSize),
    () => {}
  );

Comment on lines +33 to +38
startProcessing(org: string, id: string) {
return this._media.model.media.update({
where: { id, organizationId: org },
data: { status: 'processing', processingError: null },
select: { id: true, status: true },
});

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

Prisma's update method where parameter only accepts unique fields. Since organizationId is not a unique field or part of a unique compound index with id, this will cause a TypeScript compilation error. Since id is already a globally unique CUID, you can safely remove organizationId from the where clause.

Suggested change
startProcessing(org: string, id: string) {
return this._media.model.media.update({
where: { id, organizationId: org },
data: { status: 'processing', processingError: null },
select: { id: true, status: true },
});
startProcessing(org: string, id: string) {
return this._media.model.media.update({
where: { id },
data: { status: 'processing', processingError: null },
select: { id: true, status: true },
});
}

Comment on lines +41 to +56
finishProcessing(
org: string,
id: string,
data: { name?: string; path?: string; fileSize?: number; error?: string }
) {
return this._media.model.media.update({
where: { id, organizationId: org },
data: {
...(data.name ? { name: data.name } : {}),
...(data.path ? { path: data.path } : {}),
...(data.fileSize ? { fileSize: data.fileSize } : {}),
status: data.error ? 'failed' : 'ready',
processingError: data.error || null,
},
select: { id: true, status: true },
});

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

Prisma's update method where parameter only accepts unique fields. Since organizationId is not a unique field or part of a unique compound index with id, this will cause a TypeScript compilation error. Since id is already a globally unique CUID, you can safely remove organizationId from the where clause.

Suggested change
finishProcessing(
org: string,
id: string,
data: { name?: string; path?: string; fileSize?: number; error?: string }
) {
return this._media.model.media.update({
where: { id, organizationId: org },
data: {
...(data.name ? { name: data.name } : {}),
...(data.path ? { path: data.path } : {}),
...(data.fileSize ? { fileSize: data.fileSize } : {}),
status: data.error ? 'failed' : 'ready',
processingError: data.error || null,
},
select: { id: true, status: true },
});
finishProcessing(
org: string,
id: string,
data: { name?: string; path?: string; fileSize?: number; error?: string }
) {
return this._media.model.media.update({
where: { id },
data: {
...(data.name ? { name: data.name } : {}),
...(data.path ? { path: data.path } : {}),
...(data.fileSize ? { fileSize: data.fileSize } : {}),
status: data.error ? 'failed' : 'ready',
processingError: data.error || null,
},
select: { id: true, status: true },
});
}

Comment on lines +1042 to +1044
if (!postId.includes('_')) {
return this.videoPostAnalytics(accessToken, postId, today);
}

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

If postId is null or undefined (which is possible since releaseId is nullable in the database), postId.includes will throw a TypeError and crash the analytics worker. We should add a defensive check to return [] immediately if postId is falsy.

    if (!postId) {
      return [];
    }

    if (!postId.includes('_')) {
      return this.videoPostAnalytics(accessToken, postId, today);
    }

Comment on lines +35 to +39
origin: [
process.env.FRONTEND_URL,
'http://localhost:6274',
...(process.env.MAIN_URL ? [process.env.MAIN_URL] : []),
],

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 process.env.FRONTEND_URL is undefined, the array will contain undefined, which is invalid for CORS origin configuration and can cause runtime errors or unexpected CORS behavior. It is safer to conditionally add it only if defined.

Suggested change
origin: [
process.env.FRONTEND_URL,
'http://localhost:6274',
...(process.env.MAIN_URL ? [process.env.MAIN_URL] : []),
],
origin: [
...(process.env.FRONTEND_URL ? [process.env.FRONTEND_URL] : []),
'http://localhost:6274',
...(process.env.MAIN_URL ? [process.env.MAIN_URL] : []),
],

Comment on lines +65 to +69
<datalist id="customer-name-options">
{(data?.map((p: any) => p.name) || []).map((name: string) => (
<option key={name} value={name} />
))}
</datalist>

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 multiple customers share the same name, using key={name} will result in duplicate key warnings in React, which can cause rendering bugs. It is safer to use the map index or a unique ID as the key.

Suggested change
<datalist id="customer-name-options">
{(data?.map((p: any) => p.name) || []).map((name: string) => (
<option key={name} value={name} />
))}
</datalist>
<datalist id="customer-name-options">
{(data?.map((p: any) => p.name) || []).map((name: string, index: number) => (
<option key={index} value={name} />
))}
</datalist>

Comment on lines 640 to +645
const metrics = data.all;

if (metrics.lifetime_metrics) {
const lifetimeMetrics = metrics.lifetime_metrics;
// The requested metric types are period metrics: Pinterest returns them
// in summary_metrics, never in lifetime_metrics (that only ever carries
// TOTAL_COMMENTS / TOTAL_REACTIONS).
if (metrics.summary_metrics) {

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 data or data.all is null/undefined, accessing metrics.summary_metrics will throw a TypeError. We should use optional chaining data?.all and metrics?.summary_metrics to prevent runtime crashes.

Suggested change
const metrics = data.all;
if (metrics.lifetime_metrics) {
const lifetimeMetrics = metrics.lifetime_metrics;
// The requested metric types are period metrics: Pinterest returns them
// in summary_metrics, never in lifetime_metrics (that only ever carries
// TOTAL_COMMENTS / TOTAL_REACTIONS).
if (metrics.summary_metrics) {
const metrics = data?.all;
// The requested metric types are period metrics: Pinterest returns them
// in summary_metrics, never in lifetime_metrics (that only ever carries
// TOTAL_COMMENTS / TOTAL_REACTIONS).
if (metrics?.summary_metrics) {

Comment on lines +74 to +80
if (firstItems?.every((p) => (p?.path?.indexOf?.('mp4') ?? -1) === -1)) {
const dimensions = await Promise.all(
firstItems?.map((p) => this.getImageDimensions(p?.path)) ?? []
);
const tooBig = dimensions.findIndex(
(p) => Math.min(p?.width ?? 0, p?.height ?? 0) > 1080
);

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 getImageDimensions throws an error (e.g., if the file is not found, corrupt, or network fails), Promise.all will reject and crash the entire validation/publishing process. We should wrap the call in a try-catch block to handle individual rejections gracefully, and update the tooBig check to handle null values.

    if (firstItems?.every((p) => (p?.path?.indexOf?.('mp4') ?? -1) === -1)) {
      const dimensions = await Promise.all(
        firstItems?.map(async (p) => {
          try {
            return await this.getImageDimensions(p?.path);
          } catch {
            return null;
          }
        }) ?? []
      );
      const tooBig = dimensions.findIndex(
        (p) => p && Math.min(p.width ?? 0, p.height ?? 0) > 1080
      );

@JOY

Copy link
Copy Markdown
Author

Same code-scanning caveat as #40 (commented there): CodeQL 1 critical = SSRF false positive (runtime guarded by ssrfSafeDispatcher), ESLint 3 errors = upstream farcaster lint debt. All build/test/branding gates green (verified on the same commits via #40 and dev push runs).

@JOY
JOY (JOY) merged commit df88510 into main Sep 19, 2026
56 of 64 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