Skip to content

v0.4.8

Choose a tag to compare

@github-actions github-actions released this 08 Oct 11:06
· 62 commits to main since this release
b99e030

compare changes

⚠️ Upgrade notes

1. Migrations 040–048 run before the new image serves: ten files on a managed database, nine on Supabase.
managed+postgres: the Railway pre-deploy runs them (expect "10 applied" in its log, then pnpm db:verify:pg; postgres/migrations/043_managed_auth_identities.sql is the tenth, it only exists for the managed runner); plain PostgreSQL: pnpm db:migrate:pg; Supabase pair: supabase db push (nine). Every migration is additive or replaces a function; none rewrites a large table. 044 and 045 add columns to migrate_grants (a small table) and check constraints on it. 046 replaces two quota functions and 047 replaces handle_new_user() and rewrites only workspace slugs that fail ^[a-z0-9][a-z0-9-]{0,62}$ (a repaired workspace changes its /w/<slug> address). 048 swaps one foreign key on mcp_cloud_keys (a small table) from NO ACTION to ON DELETE SET NULL. The old image keeps working against all of them, so rolling back the image alone is safe, except that a "Migrate with Studio" grant written after 044 has no repository yet, which the old image does not know.

2. The "Migrate with Studio" bundle needs four Polar products and one origin list.
New environment variables (names only): NUXT_POLAR_STARTER_BUNDLE_PRODUCT_ID, NUXT_POLAR_PRO_BUNDLE_PRODUCT_ID, NUXT_POLAR_STARTER_YEARLY_PRODUCT_ID, NUXT_POLAR_PRO_YEARLY_PRODUCT_ID, NUXT_MIGRATE_ORIGINS (production: https://migrate.contentrain.io), and NUXT_MIGRATE_INSTALL_STATE_KEY (HS256 secret, at least 32 characters). Empty bundle product ids switch the bundle off: provision answers 502 and nothing else changes. pnpm polar:sync creates the products (dry run unless --apply); run it against production only with the owner's say-so. Point a log alert at [migrate-bundle] ALARM and [companion] ALARM.

3. The companion usage subscription ships OFF.
NUXT_POLAR_COMPANION_USAGE and NUXT_POLAR_STARTER_COMPANION_PRODUCT_ID / NUXT_POLAR_PRO_COMPANION_PRODUCT_ID stay unset in production. With the flag off no companion is created, nothing is written for it and the reconciler lists no accounts. Whether Polar bills its overage at cycle end, and to which card, is still open (see below).

4. Keys with only media:read no longer receive _media_manifest.json.
The delivery route now enforces key scopes: the manifest needs the delivery scope, while media files stay readable with delivery or media:read. Give a media-only key the delivery scope if it needs the manifest; keys with the default scope are unaffected.

Migration 046: form and comment quotas take a billing window.
046_usage_window_quotas.sql drops and recreates create_form_submission_if_allowed and create_comment_if_allowed with two defaulted window parameters (p_window_start, p_window_end). Run the migration before the new image: the new code always passes the window. The old image still works against the new functions because the parameters default to the calendar month, so rolling back the image alone is safe. Both runners (Supabase and scripts/migrate-postgres.mjs) apply it as a normal migration.

Behaviour change: forms, comments and CDN count over the billing period.
For subscribed workspaces these three meters now reset with the billing period, like the other quotas, and a yearly plan slices into monthly windows. In the transition month the usage-alert keys change from YYYY-MM to the slice start, so one extra usage alert may go out for these meters.

Fix: yearly overage lock copy.
A workspace on a yearly plan that tries to enable overage now sees that yearly plans do not bill overage, instead of the trial/not-in-subscription message.

Migration 047: workspace slugs repaired.
047_workspace_slug_repair.sql replaces handle_new_user() (it lowercased after replacing characters, so a GitHub name like ABB65 gave the slug ---65-1a2b3c4d) and rewrites only the workspace slugs that fail ^[a-z0-9][a-z0-9-]{0,62}$; valid slugs are untouched and a repeat run changes nothing. Migrate's provision refused such an account with a 502. A repaired workspace changes its /w/<slug> address; nothing else stores a slug (grants, billing, CDN, MCP and CLI use ids). The migration runs in the pre-deploy step; the old image keeps working against it.

Fix: deleting an account no longer fails on keys it created in a transferred workspace (migration 048).
mcp_cloud_keys.created_by pointed at profiles(id) with NO ACTION, so after a workspace changed owner the former owner's account could not be erased: DELETE /api/profile answered 500. 048_mcp_cloud_keys_creator_delete_safe.sql makes it ON DELETE SET NULL (the key belongs to the workspace; its creator then reads as a deleted user, like approvals and uploads already do). Run it in the pre-deploy step; the old image keeps working against it. The delete route also refuses what the screen already blocked: 409 ownership_transfer_required while an owned workspace still has other members, and 409 active_subscription while one has an active, trialing or past-due subscription that is not already set to end (cancel it first).

Enhancement: the Migrate account state carries monthly_list_cents. The sized plan's monthly price × 12 (Starter $9 → 10800), next to renewal_cents, so the Migrate offer can show the yearly saving without holding a price of its own.

Fix: provision logs why its own answer failed validation. A refused answer now logs the order, the slug and the validator's errors before the 502.

Policy change: emptying a field makes a content write bulk_content.
A field counts as emptied when it had a value before the change and is empty after it ('', null, [], {} or removed), and that includes sub-fields of objects and fields inside lists of objects. It is read from the branch's before/after, so the save and the Merge button give the same answer. Under the default policy nothing changes: bulk_content asks for the same single review. A policy that sets low_risk_content to auto now holds these writes, and the panel Merge holds them too. A blank optional sub-field that was already empty no longer lifts a save.

Policy change: removing list items makes a content write bulk_content.
This applies to lists at any depth, nested ones included. Items are matched by id/key/slug/ref when every item carries one. A list that can't be matched that way (plain values, or every item rewritten) counts only the drop in item count. Adding items or reordering them never counts. The count comes from the branch's before/after, so a save and its merge give the same answer. Under the default policy the approval count is unchanged. A policy that sets low_risk_content to auto now holds these writes, at save and at merge.

New: pnpm media:strip-metadata cleans media stored before #384 and #393 (run on request, not on deploy).
WebP masters uploaded before the EXIF fix still carry camera/GPS data, and SVGs stored before the sanitising fix still carry scripts and metadata. The script removes the EXIF/XMP chunks of a WebP losslessly (the pixel data is copied byte for byte, nothing is re-encoded; the colour profile stays) and passes stored SVGs through the upload allow-list. Paths, and so every URL, stay the same. It is a dry run unless --apply is given: the dry run reads and reports how many assets, how many WebP files carry EXIF and how many a GPS position. An apply run checks each rewritten WebP decodes to identical pixels before replacing it, then corrects the asset's size_bytes and the workspace storage counter by the difference; it is idempotent. Both modes print the public URL of every rewritten file, one per line (--urls-out <file> to save them). The CDN edge keeps the old bytes (s-maxage=3600, stale-while-revalidate=86400, and purgeCache is a no-op for R2), so purge those URLs by hand after an apply (Cloudflare "purge by URL", 30 per call; split -l 30). No migration, no new environment variables; it reads the app's own NUXT_POSTGRES_URL and NUXT_CDN_R2_*. See docs/MEDIA_INGEST.md.

Migration 040 (comments export).
managed+postgres: the Railway pre-deploy runs it (expect "1 applied" in its log, then pnpm db:verify:pg); plain PostgreSQL: pnpm db:migrate:pg; Supabase pair: supabase db push. 040 adds the table migrate_comment_exports, which holds a Migrate order's comments export on its grant until the project imports it. Nothing existing is changed or dropped, so rolling back the image alone is safe.

New environment variable: NUXT_MIGRATE_ORIGINS.
These are the Migrate origins Studio may fetch a comments export from, comma-separated (production: https://migrate.contentrain.io). It is empty by default, and empty fetches nothing: a claim's comments export is then marked unavailable and the file upload in the comments settings stays the way in. Once set, the export is fetched when the order is claimed. The claim never waits for it (10 s per attempt, one retry), and the export's payload is cleared as soon as it is imported, or when the 30-day window ends if it is never imported. The bearer token that comes with the claim is never stored or logged. @contentrain/types moves to 1.32.0.

Fix: SVG uploads to the media library are sanitised before they are stored.
Only repo ingest used to sanitise an SVG; a library upload (the media route, the public media API, the MCP media tools, chat attachments, migration media) stored the file as sent, script and event handlers included. The CDN already served media with nosniff and a sandbox CSP, so it did not run from the Studio host, but it was stored, and its <metadata>/RDF and editor data (author, file paths) were published. The provider now cleans the file with the same element/attribute allow-list repo ingest uses and stores the cleaned form; the upload still succeeds. Removed: script, on* handlers, external references, <metadata>/RDF, Inkscape/Illustrator data. Kept: viewBox, size, title/desc, defs, gradients, use, clip paths and masks. Visible change: <a> links inside an SVG are dropped with what they wrap (they are not in the allow-list). The declared type is not trusted: content that reads as an SVG takes this path whatever it was declared as (a .png that is really an SVG is stored as image/svg+xml). That detection reads the file's prolog the way an XML parser does — a comment, processing instruction or DOCTYPE with an internal subset before the root, a prefixed root (<svg:svg>) — and UTF-16 files are refused (400 media.svg_unsafe) rather than stored as sent. An SVG that cannot be made well-formed is refused with 400 media.svg_unsafe. The private source download (migration 041) is sent as an attachment with nosniff and a sandbox CSP. SVGs stored before this change are not rewritten; re-sanitising them is part of the follow-up that cleans existing assets (#391). No migration, no new environment variables.

New: the uploaded image is kept privately as its source (migration 041).
The published master is a re-encode (no EXIF/GPS, at most 4096 px), so the file as it arrived used to be lost. A new image upload (not SVG, video or PDF, which are stored as they are) now also stores the uploaded bytes unchanged at media-source/<assetId>.<ext>, outside the public media/ prefix. Project members download it from the media detail (GET .../media/{assetId}/source); no CDN, public or keyed, and no public/MCP/conversation response serves it or names it. Because it is not stripped, it can carry camera and location data, and the download says so; any project member can download it, EXIF/GPS included. It counts toward the workspace storage quota (size_bytes is the total, source_size_bytes the source's share), and deleting the asset or a failed upload removes it. Rehost/copy-assets carries media-source/ along with media/. Variant regeneration still works from the published master. Run migration 041 before the new image is served (source_path, source_size_bytes, both nullable; the upload path writes them); rolling the image back leaves the columns unused. Existing assets have no source, and nothing can recover one: their download answers 404 and the UI offers none. The API and UI now call originalPath the delivery master; the field name is unchanged because content references the path.

Fix: animated GIF and WebP uploads stay animated.
Uploads used to be cut down to their first frame without a word. An animated GIF or WebP is now kept whole — every frame, its delays and its loop — as an animated WebP master, and its WebP variants (including auto) are animated at their own size. Bounds: at most 300 frames, at most 64 megapixels summed over all frames (frames × width × height), and 30 seconds of processing. Over any of them the upload is rejected with a 400 that says which limit and how far over; it is never saved as a single frame. A variant configured as JPEG, PNG or AVIF (an og image, a favicon) is a still by its format: the first frame, at the requested size. Existing assets are not regenerated — an animation uploaded before this change stays the single frame it was stored as; upload it again to get the animation. No migration, no new environment variables.

Fix: uploaded images no longer keep their EXIF/GPS in the public master.
optimizeImage() used to re-attach the input's metadata (withMetadata), so camera model, owner and GPS position of an upload could still be read from the published WebP. It now writes no metadata at all: EXIF/GPS, XMP, IPTC and the embedded profile are dropped, the EXIF rotation is applied to the pixels, and the pixels are converted to sRGB. Variants are cut from that master, so they carry nothing either. Existing assets are not regenerated by this change — files uploaded before it still carry their metadata until they are re-uploaded. Whether a remediation step for them is needed is tracked separately. No migration, no new environment variables.

🚀 Enhancements

  • migrate: The Studio+Migrate bundle: Migrate sells its fee plus Studio year 1 as one order. Studio provisions the account (POST /api/migrate/provision, signed claim v2), opens one Polar checkout at the quoted total, moves the subscription to the yearly list product for renewal, and refuses a quote it does not agree with. An order whose account already has a covering plan joins that workspace and charges nothing. Money guards and the GitHub identity check are in place (#396, #404, #405, #407, #409, #411)
  • migrate: Refunds: a grant can be revoked (refund_before_delivery, refund_after_delivery, delivery_failed, ops), which cancels the subscription it is bound to; revoking an already ended subscription is success (#406, #408)
  • migrate: Studio setup beside a live move: install URL, grant status and a signed setup callback, with the comments export imported from the claim (#403, #383)
  • comments: The workspace owner and admins get an email when a comment arrives (#401)
  • billing: Yearly plans: honest overage-lock copy; forms, comments and CDN count over the billing window (#413)
  • billing: Companion monthly usage subscription for yearly plans, behind NUXT_POLAR_COMPANION_USAGE (off) (#415)
  • content: Relation and relations fields in the edit form are a searchable select: type to filter (case- and diacritic-insensitive, matches the label, the id and the model name), polymorphic relations are grouped by model, only the first 100 matches are drawn with a note to keep typing, and chips, reordering and the manual id fallback are unchanged (#417). A field that already has a value opens to the whole list, not only its own entry (#419)
  • migrate: The account state carries monthly_list_cents, the sized plan's monthly price × 12, so the Migrate offer can show the yearly saving; the price stays in Studio (#418)
  • migrate: One click connects a bundle or covered order's delivered repository as a project from the claim page, with a next step for every missing thing (plan_locked, no_installation, repo_not_accessible, migration_not_merged); a repository that is already a project is the answer, not an error (#422). A repository in another GitHub account says so with both accounts named, and a renamed or moved repository still connects through GitHub's redirect (#429)
  • billing: Checkout can send the customer back to the Migrate claim: POST /api/billing/checkout takes returnTo, which is kept only as an internal path under /migrate/claim and dropped otherwise (absolute URLs, //host, backslashes, .., other pages) (#423)
  • settings: Project-scoped keys move into the project: an API keys tab holds the MCP keys and the Conversation API keys; the workspace tabs become Overview, Members, Billing, GitHub, Connected apps and AI; the MCP Cloud tab is gone and the old ?tab=ai-keys / ?tab=mcp-cloud links redirect. An ee-backed feature is hidden in Community and otherwise shows an upgrade prompt that names the plans from the plan matrix (#430, #431, #432)
  • connected-apps: A copyable OpenAI Codex config.toml block (OAuth sign-in, no key in the file) (#427)
  • @contentrain/mcp 3.9.32, @contentrain/query 7.6.38, @contentrain/types 1.59.0 (typed Migrate grant-status fields, renewal_cents and monthly_list_cents from the contract) (#424, #426, #433, #434)
  • media: Uploaded images are kept as a private source; animated GIF/WebP stay animated; stored EXIF/XMP and unsanitised SVG can be swept (pnpm media:strip-metadata) (#387, #385, #394)
  • @contentrain/types 1.49.0; the Migrate golden handoff comes from the package (#410, #399)

🔒 Security

  • cdn: The delivery route enforces each key's scopes. Keys with the default scope are unaffected.
  • activity: The project activity feed follows project access; workspace owners and admins keep the full view.

🩹 Fixes

  • migrate: Workspace slugs repaired and provision logs why its own answer failed (#414); a past-due subscription gets its own provision code (#412); signed server-to-server routes pass the session middleware (#402); a held comments export lasts the grant window (#388)
  • review: An approved merge is pinned to the commit that was approved; the merge fails closed on an unreadable tip; removed list items and emptied fields count as bulk_content (#375, #389, #378, #379, #377)
  • projects: The connect is held while a Migrate delivery branch is unmerged (#398)
  • media: SVG uploads are sanitised and detected the way a parser reads them; uploaded images keep no EXIF/GPS in the public master; the sweep names the file to repair (#393, #395, #384, #397)
  • migrate: A bundle order's claim link opens its site instead of a 409, and every claim error shows Open Studio and a support link with text by its code (#421)
  • content: A relation field shows its entry when the entries load late (#420)
  • profile: Account delete refuses what the screen blocks, and a billing subscription (#425, #428)
  • comments: Only http(s) author URLs are stored and served, through one shared helper; a stored javascript: URL is no longer returned (#435)
  • settings: Wrong revoke and delete toasts, the AI panel ignoring workspace changes, members' false 403s and empty states on owner/admin routes, a Claude Code server-name collision between the OAuth and key snippets, and window.confirm replaced by an inline confirm (#430)
  • Dark-mode 950 shades, badge contrast and schedule wording (#374)

❤️ Contributors

  • AHMET BAYHAN BAYRAMOGLU (@ABB65)