Commit graph

1124 commits

Author SHA1 Message Date
Paulo Castellano
94d0156ec0 Give Instagram/Threads a wider proactive-refresh window
Extension-model tokens (Instagram/Threads) can't be refreshed once they
expire, so the shared 30-minute cron window left only a ~15-minute buffer
against queue backlog on the default queue — and a lapse forces a full
reconnect. Rotating platforms go through verify() and won't rotate a
still-valid token, so they keep the tight 30-minute window; extension
platforms now get a 24-hour lead via a per-model query.
2026-07-03 10:33:26 -03:00
Paulo Castellano
6037169aa9 Cover Threads token extension with an explicit test
The extend-while-valid path is shared by Instagram and Threads via
Platform::extendsAccessTokenOnRefresh(); add the Threads sibling of the
Instagram job test so both extension-model platforms are locked down.

Refs #126
2026-07-03 09:58:26 -03:00
Paulo Castellano
1bb67b7abb Keep Instagram/Threads tokens extended while still valid
Cold review caught a regression from the two previous commits. Instagram and
Threads use long-lived tokens refreshed by EXTENDING the access_token itself
(grant_type=ig_refresh_token / th_refresh_token) — they have no separate
refresh_token and CANNOT be refreshed once expired. The anti-over-rotation rule
("only refresh a token once it's actually expired") is right for rotating
single-use refresh_token platforms but wrong for these: it left IG/Threads
tokens to lapse, after which the extend call fails and the account disconnects
(~every 60 days).

Gate the anti-rotation on the platform's refresh model:
- Platform::extendsAccessTokenOnRefresh() — true for Instagram/Threads.
- SocialAccount::needsProactiveTokenRefresh() — expired for rotating platforms,
  OR expiring-soon for extension platforms (restores isTokenExpiringSoon).
- RefreshSocialToken extends (refreshToken) extension-model tokens while still
  valid, and verifies (access-token-first) rotating ones.
- All 23 publisher/analytics pre-checks now use needsProactiveTokenRefresh().

Tests: proactive job extends a still-valid Instagram token; a model test covers
the rotating-vs-extension branching; existing X/LinkedIn anti-rotation tests
are unchanged.

Refs #126
2026-07-03 09:18:45 -03:00
Paulo Castellano
3dd1804c8e Stop publishers/analytics from rotating still-valid tokens
Every publisher and analytics service proactively refreshed the token when
it was expired OR merely "expiring soon" (within 15 min), calling
refreshToken() directly. For X (and other single-use-refresh providers)
that rotated a perfectly valid access_token whenever an operation ran in the
token's final 15 minutes — the same needless rotation that breaks the
refresh_token chain and disconnects accounts.

Narrow every pre-check to refresh only when the token is actually expired. A
still-valid token is used as-is; if it expires mid-operation the existing
reactive retry (PublishToSocialPlatform) refreshes and retries.

- Drop `|| is_token_expiring_soon` from all 23 publisher/analytics pre-checks.
- Remove the now-unused `isTokenExpiringSoon` accessor (no references remain
  anywhere in the repo).
- The reactive retry path (AbstractLinkedInPublisher::retryWithRefresh) and
  the expired-token path are unchanged.

Refs #126
2026-07-03 08:30:58 -03:00
Paulo Castellano
2f4b974130 Fix X token chain breaking from over-rotation
X OAuth2 refresh tokens are single-use: each refresh rotates the pair and
invalidates the previous refresh_token, and reusing a rotated one kills the
whole family. Three things made this fragile and disconnected accounts far
more often than necessary:

- The proactive refresh job called refreshToken() directly, bypassing the
  access-token-first guard in verify() and rotating on every run.
- RefreshExpiringTokens used a 2h window on an hourly schedule — equal to the
  2h access-token lifetime — so every X account was rotated every hour even
  while its token was still valid.
- A single 4xx refresh failure disconnected the account without checking
  whether a concurrent refresh had already persisted a working token.

Changes:
- RefreshSocialToken now routes through verify() (access-token-first), so it
  only rotates when the access_token is actually invalid.
- Shrink the proactive window to 30m and run the command every 15m, so the
  window still covers the run interval but rotation happens near real expiry.
- verify() tolerates the lost-rotation race: on a 4xx refresh, reload and
  verify with a concurrently-refreshed token before marking TokenExpired.

Refs #126
2026-07-02 20:32:55 -03:00
Paulo Castellano
2b845f8e2f
Merge branch 'main' into fix/prompts-no-em-dash 2026-07-02 20:14:18 -03:00
Paulo Castellano
d96113a466 prompts(humanizer): enforce the no-dash rule with a final-check output gate
Reinforcement kept, but placed where it bites: a self-verification gate right
before the JSON output ('scan for — and –, rewrite until zero remain') instead
of restating the rule as a checklist step among general rewrite techniques.
The strengthened catalog entry stays as the diagnostic 'what to remove' source.
2026-07-02 19:50:20 -03:00
Paulo Castellano
1d693e3227 refactor(prompts): drop redundant em-dash checklist item in humanizer
The strengthened 'Em dashes and en dashes (top priority)' catalog entry is
the single source of truth for the rule. The numbered 'How to rewrite' list
holds general rewrite techniques, not specific tells, so restating the
em-dash rule as item 9 duplicated the (stronger) catalog entry and broke the
section's pattern.
2026-07-02 19:45:16 -03:00
Paulo Castellano
1284c46960
Merge pull request #146 from dantaspaulo/fix/workspace-content-language-locale
fix(workspace): default content_language to the app locale, not 'en'
2026-07-02 16:08:54 -03:00
Paulo Castellano
a4792300cc
Merge branch 'main' into fix/workspace-content-language-locale 2026-07-02 15:56:45 -03:00
Paulo Castellano
5ba6779c76
Merge pull request #144 from dantaspaulo/fix/media-services-null-key
fix(media): don't 500 when the Unsplash/Giphy key is set to null
2026-07-02 15:56:14 -03:00
Paulo Castellano
49e4f36f8d
Merge branch 'main' into fix/media-services-null-key 2026-07-02 15:34:45 -03:00
Paulo Castellano
b566dafbeb
Merge pull request #170 from trypostit/fix/begin-checkout-tracking
fix(tracking): fire begin_checkout when onboarding checkout starts
2026-07-02 09:25:20 -03:00
Paulo Castellano
974c076e86 fix(tracking): fire begin_checkout when onboarding checkout starts
trackBeginCheckout was defined but never called, so the begin_checkout
(GTM/dataLayer) and checkout.started (PostHog) events never fired. Wire
it into the onboarding Connect submit handler, before the redirect to
Stripe, and pass the workspace plan from the controller so the event
carries plan name + interval.
2026-07-02 09:21:31 -03:00
Paulo Sérgio Dantas
7dab106f06 fix(workspace): default content_language to the app locale, not 'en'
When a workspace is created without an explicit content_language,
`data_get($data, 'content_language')` returns null, array_filter drops the key,
and the column falls back to its DB default of 'en'. So AI content and
notifications came out in English even when the app was running in another
locale.

Default to `app()->getLocale()` instead, so a new workspace inherits the user's
current language. An explicit content_language still wins.
2026-07-01 13:49:48 -03:00
Paulo Sérgio Dantas
3a9ed2dfb3 feat(prompts): make 'no em/en dashes' an explicit rule in the content prompts
Em and en dashes (— –) are the most recognizable AI-tell in generated text. The
generator had no rule against them, and the humanizer only mentioned them weakly.

- generator: explicit rule to never use them (rewrite with a comma, parentheses,
  a colon, or two sentences; regular hyphens stay fine).
- humanizer: strengthen the existing em-dash note into a top-priority removal
  rule and add it to the numbered rewrite checklist.
- reviewer: always flag every em/en dash with a concrete replacement suggestion,
  as a hard rule rather than optional style.
2026-07-01 13:06:18 -03:00
Paulo Sérgio Dantas
4e80fd54b7 fix(media): don't 500 when the Unsplash/Giphy key is set to null
`config('services.unsplash.access_key', '')` only falls back to '' when the key
is *absent* — if the env var is present but empty, config returns null, and
assigning null to the `string` property throws a TypeError, taking the whole
request down with a 500 during media search.

Cast to `(string)` so a null/empty key degrades gracefully to '' and the service
returns an empty result set (as it already does for a missing key) instead of
throwing.

Covered by a regression test for both services.
2026-07-01 12:16:15 -03:00
Paulo Sérgio Dantas
e5658f7d06 refactor(sidebar): move Settings from the sidebar footer into the user menu
Settings lived on its own fixed row at the bottom of the sidebar. It's a
secondary destination, so it now sits inside the user dropdown (above Profile),
where it groups naturally with Profile / Language / Log out and frees a fixed
row of sidebar space.

- UserMenuContent: Settings item added above Profile.
- AppSidebar: footer Settings block removed, along with the now-unused
  IconSettings, settingsHub and useActiveUrl/urlIsActive.
2026-07-01 02:08:05 -03:00
Paulo Sérgio Dantas
67fb40d177 feat(ai-create): let users choose brand colors or free AI colors for images
The image pipeline already threads `applyBrandVisuals` through
`TemplateContext` -> `PostImagePipeline` -> `TemplateImageGenerator`, but it was
hardcoded to `true` at the dispatch site, so generated images always used the
workspace brand palette with no way to opt out.

Expose the choice in the create wizard: a "Brand colors" / "Let AI decide"
toggle (shown only when images are generated). The flag flows
front -> `StartPostCreationRequest` (`apply_brand_visuals`) ->
`PostAiCreateController@start` -> `StreamPostCreation` -> `TemplateContext`,
defaulting to `true` so existing behavior is unchanged when the field is absent.
2026-07-01 01:47:57 -03:00
Paulo Sérgio Dantas
ffd7b45eb3 feat(ai-create): show character counter under the AI prompt field
The AI post prompt is validated with `max:2000` on the backend, but the
create form gave no hint of the limit — users could write past it and only
find out via a validation error on submit.

Add a live `X/2000` counter below the "What is this post about?" textarea.
It turns red (`text-destructive`) once the prompt exceeds the limit, and the
generate button is disabled while over it (`canSubmit` now checks
`length <= PROMPT_MAX`), so the limit is caught before the request.
2026-07-01 01:22:54 -03:00
John Rallis
eb0ec6fb24 Add more content languages 2026-06-30 14:17:30 +03:00
Paulo Castellano
b0cc7ba12b feat(previews): show full vertical media without cropping
Vertical previews (stories, reels, TikTok, Shorts) rendered media full-bleed,
so a correctly-sized 9:16 image was clipped by the phone mockup, which is
taller than 9:16.

A shared VerticalMediaCanvas now renders every vertical format consistently:
images show in full with a blurred-background extension filling the gaps (no
crop), while videos stay full-bleed like the real feed. This mirrors how the
platforms themselves display the content and matches the story blur applied at
publish time.

Also reduce fitToCanvas to two image decodes instead of three (reuse the probe
as the foreground), lowering peak memory on the story publish path.
2026-06-29 15:50:24 -03:00
Paulo Castellano
bfbcd642ea feat(instagram): auto-fit story images to 9:16 with blurred background
Story images that aren't 9:16 were clipped by Instagram. They are now
fitted onto a 1080x1920 canvas — the image is contained and a blurred,
darkened copy of itself fills the letterbox gaps, so nothing is cropped
and the background color adapts to the image.

The fit happens at publish time (the hosted copy lives in social-crops/),
and the post editor preview now renders the same blurred-background fit
for stories, so the user sees exactly what will publish. The aspect-ratio
warning is suppressed for story images since they're auto-fitted.

Instagram only — Facebook stories are video-only in our flow.
2026-06-29 14:35:36 -03:00
Paulo Castellano
bfa018a4cd
Merge pull request #122 from trypostit/fix/api-media-download-host
fix: make external-URL media safe end-to-end (API hosting, X MIME, error sanitization)
2026-06-29 13:48:07 -03:00
Paulo Castellano
a63e9ea778 fix(media): treat any download failure (timeout/DNS/refused) as a failed fetch
MediaAttacher::download() only caught RuntimeException, but a connection-level
failure throws Illuminate\Http\Client\ConnectionException (extends Exception, not
RuntimeException) — e.g. a slow/unreachable proxy hitting the 20s timeout. On the
API hosting path that propagated as a 500 (not the promised 422), skipped the
batch rollback (orphaning an already-hosted item), and leaked the temp file.

Catch Throwable so any fetch failure returns null → the caller rejects cleanly
with 422 and rolls back. Also hardens the existing MCP/REST attach-from-url paths
(a timeout there now reports a failed URL instead of 500).
2026-06-29 13:41:47 -03:00
Paulo Castellano
a6bb39cc1a refactor(api): extract inline-media hosting into a HostInlineMedia action
The download+host+422 orchestration was a private controller method doing IO and
throwing — that's an operation, not a controller concern. Move it to
App\Actions\Post\HostInlineMedia::execute() (alongside CreatePost/UpdatePost) so
the controller stays thin and the logic is reusable/testable.
2026-06-28 21:15:01 -03:00
Paulo Castellano
41cb9b02a3 test(api): cover unsupported-type and mixed-batch inline media scenarios
- External URL that downloads (200) but isn't a supported media type → 422,
  nothing persisted (the type-rejection branch, distinct from a download 404).
- Mixed batch: an already-hosted item + an external URL both succeed → both kept
  in order, only the external one creates a Media row.
2026-06-28 21:15:01 -03:00
Paulo Castellano
df891574f7 fix(api): roll back hosted media when an inline url batch partially fails
Final-review follow-ups:
- MediaAttacher::resolveInlineMedia now deletes the media it hosted in this call
  when any item fails, so a mixed [good, bad] batch no longer orphans the good
  item's Media row + file while the request is correctly rejected with 422. Makes
  the create/update media resolution truly all-or-nothing.
- PostMediaRules: keep source/source_meta on both contracts (the API previously
  passed them through with no item rules — don't silently drop them) so the media
  item shape is uniform; only id/path/url differ by contract.
- Make MediaAttacher::fetchToWorkspace private (no external callers).
- Test the partial-batch rollback (no Media, no post persisted).
2026-06-28 20:59:21 -03:00
Paulo Castellano
1242cc7384 refactor(posts): centralize inline media validation in PostMediaRules
The media.* rules were duplicated across the web update request and both API
requests (and diverged: web requires hosted id+path and tracks source; the API
accepts a bare external url it downloads). Pull them into one
App\Support\PostMediaRules::rules(hosted:) — same pattern as PostPlatformMetaRules
— parameterized by contract, so there's a single place to add a media key and the
validated()-strips-unlisted-keys footgun can't drift between entry points.

Behavior is unchanged (each ruleset is reproduced exactly). Web store keeps its
loose 'media' => array (no item rules) and is left out on purpose — adding strict
rules there would change the web create contract.
2026-06-28 20:43:09 -03:00
Paulo Castellano
8d7dcdf6eb refactor(social): trim verbose comments + harden X chunked upload from review
Cold-review follow-ups on the PR:
- Trim the oversized docblocks/inline comments added across the API controller,
  MediaAttacher, Post, the publish job, and the X publisher to one line (keeping
  the @param/@return array-shape annotations).
- XPublisher::chunkedUpload now accepts ?string $mediaCategory and only sends
  media_category when present — getMediaCategory() can return null, so the strict
  string param was a latent TypeError (unreachable on X today, removed anyway).
- Fix MediaAttacher docblocks: the file imports Type as MediaType, so the
  @param array<Type> annotations didn't resolve — now array<MediaType>.
- Tests: cover the failed() job hook genericizing a raw error, and X failing
  cleanly (XPublishException) when media can't be downloaded.
2026-06-28 20:37:04 -03:00
Paulo Castellano
5b26747dde refactor(social): type the deliberate publish exceptions so they keep a clean message
After the failure-email sanitization, any non-SocialPublishException is shown to
the user as a generic line. The deliberate, user-facing throws in the X,
Facebook, Threads, and LinkedIn (+Page) publishers were plain \Exceptions, so
they'd have been genericized too — losing actionable messages like 'Unsupported
media type for Facebook' or 'X posts require either text or media'.

Convert those throws to their platform's SocialPublishException with a vetted
userMessage + category, so the publish failure surfaces a specific, safe message
(and the catch-all's generic line is reserved for genuinely unexpected errors).

Left as plain \Exception on purpose: the download helpers in Bluesky and Mastodon
(caught internally, returned as null — they never reach the email), and the
shared content-length guard in HasSocialHttpClient (no per-platform exception in
the trait, message is path-free, and it's already validated upstream).
2026-06-28 20:16:16 -03:00
Paulo Castellano
08e64ae074 fix(publish): never surface raw internal errors in the failure email
The post-failure email renders each platform's stored error_message verbatim.
On an unexpected publish error the job's catch-all (and the job-failed hook)
stored the raw exception message — for a PHP TypeError that includes the server
file path (.../releases/<id>/app/Services/Social/XPublisher.php on line 130),
which then reached the customer's inbox.

Only our own SocialPublishException carries a vetted, user-facing message.
Every other throwable (engine errors like TypeError, or library exceptions such
as Intervention's decoder errors which embed temp paths) is now replaced with a
generic line; the raw detail stays in the logs. Token-expired and rescheduled
paths are unchanged.

Note: publishers that still throw plain \Exception for user-facing reasons
(some X/Facebook/Threads/LinkedIn cases) will now show the generic message for
those; converting them to typed SocialPublishExceptions to restore specific
copy is a worthwhile follow-up.
2026-06-28 20:02:35 -03:00
Paulo Castellano
4417aa99ac fix(x): recover a missing media MIME from the downloaded bytes
A post created via the API with bare external-URL media (no mime_type, no
path to infer one from) reached XPublisher::getMediaCategory() with a null
$mimeType — whose parameter is typed string — throwing a TypeError. The
TypeError's message embeds the server file path, which then surfaced verbatim
in the user's failure email.

X downloads the media bytes anyway, so recover the MIME from the downloaded
file when the item carries none, and fail with a clean XPublishException
(MediaFormat) rather than a TypeError when it still can't be determined. Also
drive the image/video decision off the resolved MIME so sniffed images are
still optimized.

Audited every publisher: X was the only one whose strict mime path was
reachable with null (no isImage() gate + no path to backfill from). Bluesky's
uploadBlob is also strictly typed but is guarded by an isImage() check plus the
DTO's extension-based MIME backfill; the pull-from-URL publishers classify via
nullable helpers and fail cleanly; Pinterest/Mastodon already sniff; YouTube
and Facebook video fall back to video/mp4.
2026-06-28 19:29:14 -03:00
Paulo Castellano
81d43c30f4 fix(api): download and host external media URLs on post create/update
The public REST API accepted inline post media as a free-form array and stored
it verbatim, so a client could create/update a post whose media was a bare
external URL we never hosted. Publishing then depended on that third-party URL
staying alive — when it 404'd (e.g. an image proxy), the post failed across
platforms (Facebook 'unsupported media type', X 'HTTP 404', Instagram 'could
not fetch media').

Inline media URLs on create/update now go through the same download + MIME-
validate + host path as the attach-from-url endpoint (MediaAttacher), so the
stored media always points at our own storage. Items already hosted (carrying a
path) pass through untouched. If any URL can't be fetched the request is
rejected with 422 and nothing is persisted, so a post is never created with
broken media. MCP and the web flow were already safe and are unchanged.

- MediaAttacher: extract fetchToWorkspace() + add resolveInlineMedia()
- Post::allowedMediaTypesFor() so the create flow can compute allowed types
  without a persisted post
- API Store/UpdatePostRequest: media.* item rules (mirroring the web; prevents
  validated() from stripping hosted-item keys)
- PostController store()/update(): host external media before persisting
2026-06-28 17:28:05 -03:00
Paulo Castellano
80dc1ab381
Merge pull request #120 from trypostit/fix/tiktok-photo-resize
fix(tiktok): resize oversized photos before PULL_FROM_URL
2026-06-26 13:36:38 -03:00
Paulo Castellano
ffa4377615 test(tiktok): fake against the config base URL across all TikTok test files
Convert TikTokCreatorInfoTest and TikTokPublishExceptionTest to fake against
config('trypost.platforms.tiktok.api') instead of the hardcoded host, matching
the TikTokPublisherTest cleanup. TikTokControllerTest is left as-is: its only
TikTok URL is a Socialite authorize stub, which has no config counterpart.
2026-06-26 13:13:15 -03:00
Paulo Castellano
5df854092b test(tiktok): cover cleanup edge cases and fake against the config base URL
- Multi-image carousel: only the oversized photo is resized, order preserved,
  the compliant one passes through untouched.
- Cleanup runs even when publishing fails (no orphaned derivative).
- A throwing Storage::delete does not turn a successful publish into a failure.
- Replace hardcoded https://open.tiktokapis.com/v2 with
  config('trypost.platforms.tiktok.api') so the fakes can't drift from the
  configured (overridable) host.
2026-06-26 13:08:18 -03:00
Paulo Castellano
3cf9af9caf fix(tiktok): keep photo derivative cleanup from masking a successful publish
The default disk (r2) is configured with throw => true, so Storage::delete
re-throws on a failed delete. Running that inside the finally meant a delete
failure could discard the publishPhotos return value and surface an
already-live TikTok post as a failure (risking a duplicate re-publish).

Move cleanup into a pruneDerivatives helper that swallows storage errors, and
wrap the resize/host step so decoder/storage failures surface as a categorized
TikTokPublishException instead of an uncategorized error.
2026-06-26 13:08:11 -03:00
Paulo Castellano
0829814d8b test(tiktok): cover download-failure path and assert TikTok-specific optimization
Adds a test for the resize download-failure branch, and tightens the resize
mocks to assert optimization is requested for Platform::TikTok specifically.
2026-06-26 12:42:39 -03:00
Paulo Castellano
13e4d88b71 test(tiktok): cover MediaItem dimensions and platform max-width helpers
Direct unit tests for the new MediaItem::width()/height() accessors and
MediaOptimizer::maxWidthForPlatform(), plus drop a redundant inline comment.
2026-06-26 12:31:46 -03:00
Paulo Castellano
c5fca040a9 fix(tiktok): resize oversized photos before PULL_FROM_URL
TikTok photo posts hand the platform our media URLs and let it pull the
bytes, so they never went through MediaOptimizer like the upload-based
publishers do. An image wider than 1080px reached TikTok untouched and was
rejected with picture_size_check_failed ("Image dimensions exceed limits").

Photos within spec still pass through unchanged. Oversized ones (or images
with unknown dimensions) are downloaded, resized to TikTok's 1080px spec,
hosted on the public disk, and that URL is handed to TikTok instead. The
derivative is pruned once TikTok has pulled it.

Reuses the same hosted-derivative pattern Instagram/Threads already use for
aspect-ratio cropping; the TikTok max_width config was previously dead code.
2026-06-26 12:23:56 -03:00
Paulo Castellano
98f13d38b5
Merge pull request #119 from trypostit/chore/release-email-unsubscribe
chore(release): add unsubscribe footer to the customer email template
2026-06-26 11:10:22 -03:00
Paulo Castellano
0ee5d807a2 chore(release): add unsubscribe footer to the customer email template 2026-06-26 11:02:06 -03:00
Paulo Castellano
1d31fa6d9b
Merge pull request #118 from trypostit/docs/readme-supported-platforms
docs(readme): add Telegram & Discord, fix doc links and org slug
2026-06-26 10:50:29 -03:00
Paulo Castellano
8d14ad2268 docs(readme): reposition around product value, fix self-host and mcp links
Drops the Buffer/price-comparison framing for product-led positioning (one
calendar, AI copilot, MCP, native publishing) plus an own-your-stack section,
a hero image, and brand colors. Fixes the self-host link to
/self-hosting/overview and the MCP link to /ai/introduction.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 10:46:38 -03:00
Paulo Castellano
34e289b18f docs(readme): update logo link to relative path for improved portability 2026-06-26 10:40:35 -03:00
Paulo Castellano
b7cd1cf22b docs(readme): update logo link, enhance feature descriptions, and improve layout
- Changed logo source to a direct GitHub link for better accessibility.
- Revised feature descriptions to highlight the AI copilot and multi-platform capabilities.
- Improved layout and organization of the README for clarity and visual appeal.
2026-06-26 10:39:34 -03:00
Paulo Castellano
7bc7e8b82e docs(readme): fix platforms, doc links, org slug; drop stale sections
- add Telegram and Discord to Supported Platforms (3x4 grid)
- point documentation links at docs.trypost.it (was trypost.it/docs)
- correct the GitHub org slug trypost-it -> trypostit (broken links)
- drop the "TryPost 1.0 is here" announcement and the non-existent Roadmap link
2026-06-26 10:30:12 -03:00
Paulo Castellano
caf4369b4d
Merge pull request #117 from trypostit/chore/release-v1.0.5-artifacts
chore(release): add v1.0.5 changelog + customer email artifacts
2026-06-26 10:24:05 -03:00
Paulo Castellano
bcb3038b96 chore(release): drop cloud-onboarding section, point CTA to GitHub Discussions 2026-06-26 10:19:54 -03:00