Skip to content

release: 2026-08-29 - #87

Merged
teetangh merged 17 commits into
prodfrom
release/2026-08-29
Aug 29, 2026
Merged

release: 2026-08-29#87
teetangh merged 17 commits into
prodfrom
release/2026-08-29

Conversation

@teetangh

Copy link
Copy Markdown
Collaborator

Promotes dev (1a14fd8) to production. Six PRs since the 2026-08-27 release.

Included

PR What
#85 Duplicate issuer on co-branded credentials — the public verify page printed Issued by X × X
#82 Sentry environment split — local dev was reporting into the production project (563 dev events vs 2 prod in 30 days)
#78 Landing hero cards now link to real published catalog items; fabricated engagement numbers removed
#80 Public site unpinned from dynamic rendering — landing TTFB 2.1–11.5s → ~0.17s; also fixes a live production 500 on /courses?level=<invalid>
#81 Lesson videoAssetId gated by lesson type and tenant-verified — closed a cross-tenant DRM playback exposure
#77 Tenant.type enforced at the studio/org route and action guards. Closes #47

#77 closes the gap carried forward from the 2026-08-27 release: requireTenantMember checked org membership but not Tenant.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.ts that would have left four enterprise SSO actions callable by CREATOR tenants. The PR remains open and unmerged.

Verification on dev @ 1a14fd8

pnpm typecheck clean · pnpm lint 0 errors (3 pre-existing warnings) · 201 tests pass. CI green on every constituent PR.

teetangh and others added 17 commits August 27, 2026 14:51
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
@netlify

netlify Bot commented Aug 29, 2026

Copy link
Copy Markdown

Deploy Preview for elluminar ready!

Name Link
🔨 Latest commit 1a14fd8
🔍 Latest deploy log https://app.netlify.com/projects/elluminar/deploys/6a92de8219cbe90008f0fd82
😎 Deploy Preview https://deploy-preview-87--elluminar.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@coderabbitai

coderabbitai Bot commented Aug 29, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: de3fe6e6-55d4-46a2-bf82-c19628b848a5

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Comment @coderabbitai help to get the list of available commands.

@teetangh
teetangh merged commit 3b717b5 into prod Aug 29, 2026
9 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.

1 participant