release: 2026-08-29 - #87
Merged
Merged
Conversation
The three hero cards showed hardcoded titles for courses that do not
exist ("Distributed Systems in Go", "Realtime ML Pipeline", "Shipping
LLM Products"), were not links, and went nowhere. They now render real
PUBLISHED + MARKETPLACE catalog rows and link to their detail pages.
- New `src/lib/marketing/hero-cards.ts`: pure, unit-tested mappers that
build each card's href, subtitle and badge from real fields only.
- New `src/lib/marketing/hero-picks.ts`: one deterministic, indexed
query pair wrapped in `unstable_cache` (300s, tag
`marketing:hero-showcase`), never throwing.
- Removed the fabricated heart counts (173/200/436) and the 75%
progress bar. Pre-launch there is no engagement to report, and the
progress bar depicted a logged-out visitor's progress. The glass pill
now carries a factual catalog attribute (course level / project
duration); missing attribute means no pill.
- Design unchanged otherwise: same gradients, decorative geometry,
glass icon tiles and play/pause affordance (now decorative and
aria-hidden, since the whole card is the link).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`(marketing)/layout.tsx` awaited `getSession()` to decide one header button.
`getSession()` reads `headers()`, and a request-scoped read anywhere in a
route's server render tree pins that route to dynamic rendering — so the read
pinned the ENTIRE marketing segment. The CI route table shows it plainly:
`ƒ /`, `ƒ /terms`, `ƒ /privacy`, `ƒ /refund-policy`, `ƒ /contact` — pure
content pages, none of which read any data, all rendered on demand. Routes
outside the group that touch no session are `○ /sign-in`, `○ /sign-up`,
`○ /forgot-password`.
Dynamic rendering makes Next emit `cache-control: private, no-cache, no-store`,
so nothing is cacheable at the CDN and every navigation invokes the function.
Measured on production, both Netlify caches confirm the bypass:
cache-status: "Netlify Durable"; fwd=bypass
cache-status: "Netlify Edge"; fwd=miss
Eight concurrent cold requests to `/` split bimodally — four at 1.6–2.1 s and
four at 10.8–11.1 s, nothing in between — which is the founder's reported
"10–15 seconds" on the Browse CTAs and the navbar. Warm, the same page answers
in 0.51–0.79 s. `/` reads no data at all, so all of that was avoidable.
Fixes:
- Resolve the signed-in/signed-out header affordance in the BROWSER via
`authClient.useSession()` (new `HeaderAuth`, and `MobileNav` now reads the
session itself instead of taking a `signedIn` prop). The layout performs no
request-scoped reads, so `/` and the content pages can be prerendered and
served from the edge with no function invocation at all.
- Cache the catalog browse queries. `/courses` and `/projects` read
`searchParams`, so they stay dynamic and genuinely do invoke the function —
but they need not pay the database round trip. Netlify runs these functions
in us-east-2 while Supabase is in ap-south-1, and that shows up as ~0.6–0.9 s
of warm render time (`/` 0.54 s vs `/courses` 1.18 s, `/projects` 1.47 s).
The Netlify runtime backs Next's Data Cache with Blobs, so `unstable_cache`
entries are durable and shared across instances.
Two constraints held deliberately in the cache layer: free-text `q` is NOT
cached (unbounded key cardinality would thrash the cache), and what is cached
is the mapped card DTO rather than the raw Prisma row — `Price.amountMinor` is
a `bigint` and `JSON.stringify` throws on BigInt, so caching rows would fail at
runtime while passing `tsc` cleanly.
`/pricing` and `/cart` keep their server-side `getSession()`: both render
genuinely per-user content (current subscription, cart contents), so they are
correctly dynamic.
Prior art: familiarise_web hit the same class of problem and recorded the
finding that ISR/static cache hits pay no cold-start stall, while
`force-dynamic` on public pages forfeits the CDN by construction.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…rough
CodeRabbit flagged `level`/`tier` reaching a Prisma enum filter via `as never`.
It is not hypothetical and it is not new to this PR — verified live against
production before the fix:
https://elluminar.netlify.app/courses?level=NOT_A_REAL_LEVEL -> 500
Both `/courses` and `/projects` read the value straight off `searchParams` and
cast it, so any unrecognised string makes Prisma throw
`PrismaClientValidationError` and the public catalog page returns a hard 500.
Anyone can trigger it from the URL bar, and every hit is Sentry noise.
`asCourseLevel` / `asProjectTier` now validate against the enum values and
return `undefined` for anything else, so an unrecognised filter is treated as
**absent** rather than fatal — a filter nobody can express isn't worth a failed
page, and that matches how `category` already behaves. All four `as never`
casts are gone; the only remaining occurrence of that string is the comment
explaining why they were a problem.
Tests: 124 (+7) — every valid value for both enums, plus casing variants, empty
string, a SQL-ish string, and the cross-enum case (`?tier=BEGINNER` and
`?level=CAPSTONE` must both be rejected, since the two filters sit on adjacent
routes).
upsertLesson wrote `videoAssetId: videoAssetId ?? null` unconditionally. The only `type === "VIDEO"` check in the action gates the external-URL escape hatch that creates a VideoAsset, not the persisted field — so an ARTICLE, QUIZ, RESOURCE or EMBED lesson could hold a video asset no surface renders, and retyping a VIDEO lesson left the stale id behind on the row. The id also flowed from parsedInput straight into the write with no proof the asset belongs to ctx.tenant.id — the only tenant scoping in that block is on the newly created external asset. An editor in tenant A could attach tenant B's VideoAsset to their own lesson; getVideoPlayback resolves by id alone, so tenant A's learners would then get signed playback for tenant B's video. - videoAssetIdForLessonType(type, id) — pure predicate, VIDEO keeps the id, every other type writes null (a write, not undefined, so the transition clears the stale value). - assertVideoAssetInTenant — same shape as attachLessonResources in src/actions/resources.ts; one message for missing and someone-else's so the error can't probe ids across tenants. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ry project Over the last 30 days the production Sentry project took 563 events from `environment: development` and 2 from real deploys. Every unresolved issue but two is a laptop: localhost URLs, `server_name: Kaustavs-MacBook-Pro.local`, and stack frames rooted in `/Users/kaustavghosh/.../.claude/worktrees/...`. The real signal was buried under Turbopack panics and P2021 "table does not exist" errors from un-migrated local databases. Two causes, both fixed here: 1. Every Sentry config fell back to a hard-coded DSN when NEXT_PUBLIC_SENTRY_DSN was unset, so *any* clone reported into production whatever the developer did with their .env. The fallback is gone — an unset DSN now means "do not report". The DSN is set on Netlify for all contexts. 2. No `environment` was set, so Sentry inferred it from NODE_ENV. That cannot distinguish a Netlify production deploy from a PR preview: LMS_WEB-J was a deploy-preview-72 error tagged `production`. Both answers now come from the Netlify deploy context via the existing NEXT_PUBLIC_DEPLOY_CONTEXT plumbing (src/lib/deploy-context.ts) rather than a parallel mechanism. Production, deploy-preview and branch-deploy all still report, each with its own `environment` tag; only laptops go quiet. NEXT_PUBLIC_SENTRY_FORCE_ENABLE=true re-enables reporting locally on purpose. `sentryEnabled` is an allowlist rather than `!== "local"`, matching the existing `showAllSurfaces` convention: an unrecognised CONTEXT falls through to disabled, so a misconfigured build is silent instead of flooding production. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Found during a browser tour of the open PRs. The public credential page rendered
Issued by Nalanda University × Nalanda University
for every program certificate, because a program run by the same organisation
that awards it stores that organisation in both `issuer` and
`metadata.coBrandPartner`, and the page concatenated them unconditionally.
Small, but it lands on the one page a sceptical employer actually reads — the
page the whole "verifiable credential" pitch rests on. Three seeded program
credentials show it today (NX7K-4MPQ-W2TR, NX9P-2KTM-R7WD, NX4B-8HLQ-Z3VG).
Fixed in code rather than in the seed: the data is legitimate — the org really
is both issuer and partner — so the presentation is what was wrong, and any
tenant that co-brands with itself would have hit this again.
`issuerLine()` is extracted as a pure helper and compares case-insensitively
after trimming, since credential metadata is hand-entered and "nalanda
university" is the same institution as "Nalanda University".
Tests: 119 (+4).
The Sentry block claimed reporting is off locally "regardless of these", which the NEXT_PUBLIC_SENTRY_FORCE_ENABLE escape hatch two lines below contradicts — and force-enable on its own still sends nothing, because Sentry.init has no DSN fallback any more. Say both. Comment-only change to .env.example; no behaviour change. Part of #82 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`credential.metadata` is an untyped JSON column, so the
`as { coBrandPartner?: string }` cast on the verify page was a compile-time
promise only. A non-string that reached the row some other way would land on
`issuerLine`'s `.trim()` and throw — a 500 on the public credential page
instead of the issuer fallback.
`coBrandPartnerFrom()` does the narrowing where the JSON is read, so
`issuerLine` stays pure and typed. Every live row is a string today, so this
changes nothing that renders now; it closes the boundary.
Part of #85
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
fix(verify): stop printing the issuer twice on co-branded credentials
…rrors fix(observability): stop local dev reporting into the production Sentry project
feat(marketing): hero cards link to real published catalog items
perf(marketing): unpin the public site from dynamic rendering
fix(studio): gate lesson videoAssetId by type and verify its tenant
requireTenantMember checked organization membership and org role but never Tenant.type, so a member of a UNIVERSITY/ENTERPRISE tenant could open /studio/<their-slug> — the creator studio — and author courses under an org brand. The sidebar already hid the surface (getAccessibleSurfaces only offers Creator studio for CREATOR tenants), so this was nav-gated but not route-gated. - new pure predicate lib/auth/tenant-access.ts: canAccessTenantType + tenantHomePath + STUDIO_TENANT_TYPES / ORG_TENANT_TYPES - requireTenantMember takes an optional allowedTypes; requireStudioTenant / requireOrgTenant wrap it so a new page cannot forget the constraint - tenantActionClient takes allowedTypes; studioActionClient / orgActionClient wrap it, and every tenant-scoped server action now declares its surface — a route guard alone would repeat the #67 mistake - /studio index no longer shortcut-redirects an org-only member into a studio - platform-admin override and showAllSurfaces() preview behaviour preserved - denied users redirect to their tenant's real surface, never a 404 Closes #47 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The content-team default that suits the studio would quietly hand `instructor` license/roster/SSO authority in the org portal. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CodeRabbit flagged the admin-role literal duplicated between the studio index and `requireStudioTenant`. It was actually at six call sites, and two of them omitted the `?? "user"` default: src/lib/auth/session.ts (role ?? "user") === "admin" src/app/(studio)/studio/page.tsx (role ?? "user") === "admin" src/app/(studio)/.../layout.tsx role === "admin" src/actions/learning.ts (role ?? "user") === "admin" src/lib/nav/surfaces.ts (role ?? "user") === "admin" src/lib/auth/index.ts role === "admin" Equivalent today, since `undefined === "admin"` is false either way — so this fixes no live bug. It matters because the whole point of this PR is that route enforcement and nav filtering must not be able to disagree about who may open a surface, and two spellings of the same predicate is exactly how that drift starts. The `canGrade`/`hasOrgRole` consolidation in #67 was the same argument. `isPlatformAdmin(role)` now lives in `src/lib/auth/roles.ts` alongside them, and the five session-shaped call sites use it. Left alone: the BetterAuth `organizationLimit` callback in `auth/index.ts`, which receives a differently typed `user` from the plugin rather than a session — casting it through the shared helper would add coupling for no safety gain. Tests: 149 (was 146) — exact match, missing/null/empty defaulting to "user", and near-misses like "Admin" and "administrator".
fix(auth): enforce Tenant.type at the studio/org route and action guards
✅ Deploy Preview for elluminar ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Organization UI Review profile: ASSERTIVE Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Comment |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Promotes
dev(1a14fd8) to production. Six PRs since the 2026-08-27 release.Included
Issued by X × X/courses?level=<invalid>videoAssetIdgated by lesson type and tenant-verified — closed a cross-tenant DRM playback exposureTenant.typeenforced at the studio/org route and action guards. Closes #47#77 closes the gap carried forward from the 2026-08-27 release:
requireTenantMemberchecked org membership but notTenant.type, letting an enterprise/university member reach a creator studio route.Deliberately excluded
#79 (auth / onboarding / SSO) stays out. It halted on a guard-choice conflict in
src/actions/org-sso.tsthat would have left four enterprise SSO actions callable by CREATOR tenants. The PR remains open and unmerged.Verification on
dev@1a14fd8pnpm typecheckclean ·pnpm lint0 errors (3 pre-existing warnings) · 201 tests pass. CI green on every constituent PR.