Skip to content

feat(billing): ask for a GitHub reconnect when program membership cannot be verified - #3185

Open
lohanidamodar wants to merge 5 commits into
mainfrom
feat-program-membership-reconnect-github
Open

feat(billing): ask for a GitHub reconnect when program membership cannot be verified#3185
lohanidamodar wants to merge 5 commits into
mainfrom
feat-program-membership-reconnect-github

Conversation

@lohanidamodar

Copy link
Copy Markdown
Member

Console half of appwrite-labs/cloud#5459. That PR adds the backend state and the two remarks codes this reads.

What does this PR do?

Appwrite Cloud gives education organizations a free plan through the GitHub Student Developer Program and re-validates that membership against GitHub. Validation uses the OAuth refresh token on the user's GitHub identity — and GitHub refresh tokens expire after six months, while revoking the OAuth app invalidates them immediately.

When that happens the backend cannot verify membership at all. Until now the console said nothing: the check simply failed and the organization drifted on a plan nobody could confirm. Cloud #5459 makes the backend warn the owners three times and then restrict the organization; this is the console side of that.

Re-authorizing needs no backend change — the OAuth callback binds to whoever is currently logged in regardless of how they signed in, and overwrites the stored tokens on the existing identity. So putting the user through the GitHub OAuth flow is the entire fix.

Changes

src/lib/stores/billing.ts — constants for the two new remarks codes next to billingLimitOutstandingInvoice, and two branches in checkForUsageLimit, inserted after the outstanding-invoice branch:

  • Restrictedstatus === readonly with either new code: header alert, readOnly.set(true), return. Mirrors the outstanding-invoice branch.
  • Warning — organization still active with programMembershipUnverifiedSince set: header alert only. No return, and readOnly untouched, so the budget/limit/notification paths below run unchanged. This is the window that matters — the org is warned while access is still intact.

Two alert ids rather than one, because headerAlert.add is a no-op when the id already exists; a single id would pin the importance to whichever state was seen first. Restricted uses 11 (matching TeamReadonlyAlert); the warning uses 9, below budgetLimit at 10, so a genuinely blocked org still leads with the blocking banner.

organization-[organization]/programMembershipAlert.svelte (new) — modelled on teamReadonlyAlert.svelte, same HeaderAlert/Button shape and hideBillingHeaderRoutes guard, written in runes per AGENTS.md. Three mutually exclusive states, and the copy differs meaningfully between them: program_membership_invalid means GitHub has confirmed the account is no longer a student, so reconnecting cannot help and the button points at plans instead.

src/lib/helpers/github.tsreconnectGithubIdentity(), the createOAuth2Session call lifted from (guest)/education/+page.svelte with the same read:user / user:email scopes, returning the user to where they were. Shared rather than duplicated because the scopes are what mint the refresh token, and the two entry points must not drift.

account/identities.svelte — a Reconnect action per GitHub identity. That table previously offered only delete, so there was no deliberate way for a user to refresh a stale token. trackEvent fires before the call, since the OAuth flow redirects away and never returns.

Copy

Warning (active) — type="warning", "Reconnect GitHub to keep your education plan":

We can't verify the Appwrite Education Program membership for {org} because its GitHub connection expired or was disconnected. Reconnect GitHub to keep this organization on its current plan — if the connection isn't restored, access to resources will be restricted.

Restricted, unverifiedtype="error", "Access restricted":

We couldn't verify the Appwrite Education Program membership for {org} because its GitHub connection expired or was disconnected, so access to resources has been restricted. Reconnect GitHub to restore access.

Restricted, invalidtype="error", "Access restricted":

GitHub is no longer confirming student status for {org}, so this organization is no longer eligible for the Appwrite Education Program and its access to resources has been restricted. Choose a plan to restore access.

The warning deliberately names the mechanical cause and avoids implying lost eligibility — a token dies at six months through no action by the member.

Test Plan

bun run check       → COMPLETED 3770 FILES 0 ERRORS 92 WARNINGS
bun run lint        → 0 errors (exit 0)
bun run build       → ✓ built, Wrote site to "build"
bun run test:unit   → Test Files 1 failed | 14 passed, Tests 268 passed

None of the 92 check warnings or the lint warnings are in the changed files — both counts are the pre-existing baseline. The one failing unit suite (oauth2-cimd.test.ts, TypeError: Invalid URL from an absent .env) was confirmed pre-existing by stashing the change and re-running: byte-identical failure, same 268 passing.

No tests added. vite.config.ts configures a client vitest project for src/**/*.svelte.{test,spec}.ts, but no such file exists in the repo — all 15 suites are pure-logic tests on helpers and stores, and billing.ts has no existing test file. There is no component-test pattern here to follow.

Manual QA once cloud#5459 is deployed:

  1. Education org, creator's GitHub identity intact → no banner.
  2. Revoke the OAuth app, run task-billing-validate-github-student --dryRun=false → org stays active, warning banner appears, Reconnect GitHub starts the OAuth flow and returns to the same page.
  3. After reconnecting, re-run the task → banner clears, org still active.
  4. Force status=readonly + remarks=program_membership_unverifiederror banner, Reconnect offered.
  5. Force remarks=program_membership_invaliderror banner, no GitHub action, View plans instead.
  6. Account → Identities: Reconnect on a GitHub identity starts the flow.

Known gap

programMembershipUnverifiedSince is not yet in the pinned @appwrite.io/console SDK model, so it is read through one documented structural cast in billing.ts, following the idiom already used for accessedAt and labels in auth/user-[user]/. When cloud#5459 merges and the SDK pin is bumped, delete the cast from that one function body — nothing else changes.

Out of scope, worth flagging

$readOnly is cause-agnostic but the copy attached to it is not. organization-[organization]/members/+page.svelte:108-110 and header.svelte:162-169 gate the Invite button on $readOnly and then render "Upgrade to add more members" / "You've reached the members limit for the {plan} plan"; +page.svelte:63-70 gates project creation the same way. A student org restricted for program_membership_unverified will see that plan-limit wording, which is misleading. Fixing it properly means threading a restriction reason through those components.

…erified

A GitHub refresh token dies at six months, and revoking the OAuth app kills it
outright. Either way Appwrite can no longer confirm Education Program
membership, and until now the console said nothing — the check simply failed
and the organization drifted on a plan nobody could verify.

checkForUsageLimit gains two branches. While the organization is still active
and programMembershipUnverifiedSince is set, a warning banner asks for a
reconnect without touching readOnly, so the budget and limit paths below it
run unchanged. Once the organization is restricted the banner becomes an
error, and program_membership_invalid gets its own copy: GitHub has confirmed
the account is no longer a student, so reconnecting cannot help and the action
points at plans instead.

Reconnecting needs no backend work — the OAuth callback binds to whoever is
logged in and overwrites the stored tokens — so both entry points reuse one
helper, keeping the scopes that mint the refresh token from drifting apart.
Account settings gains the same action per GitHub identity, which previously
offered only delete.
@appwrite

appwrite Bot commented Aug 26, 2026

Copy link
Copy Markdown

Console (appwrite/console)

Project ID: 688b7bf400350cbd60e9

Sites (1)
Site Status Logs Preview QR
 console-stage
688b7cf6003b1842c9dc
Ready Ready View Logs Preview URL QR Code

Tip

Schedule functions to run as often as every minute with cron expressions

@greptile-apps

greptile-apps Bot commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

The PR adds GitHub reconnection prompts for education-program verification failures and a reconnect action for GitHub identities. The latest changes also reconcile program-membership alerts and read-only state when membership fields change without changing the selected organization ID.

  • Adds warning and restricted membership alerts with reconnect or plan-selection actions.
  • Centralizes the GitHub OAuth reconnection flow.
  • Rechecks canonical billing restrictions on same-organization membership transitions.
  • Adds analytics events for reconnection actions.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains; the current implementation clears stale membership alerts and recomputes read-only state when warning, restricted, or reverified membership state changes under the same organization ID.

Important Files Changed

Filename Overview
src/lib/stores/billing.ts Adds membership-state constants and synchronizes warning/restricted alerts through the canonical usage-limit check.
src/routes/(console)/+layout.svelte Re-runs billing reconciliation when membership status changes for the currently selected organization, resolving the previously stale read-only and alert states.
src/routes/(console)/organization-[organization]/programMembershipAlert.svelte Renders mutually exclusive warning, reconnect, and plan-selection states for education-program verification outcomes.
src/lib/helpers/github.ts Centralizes the GitHub OAuth flow used to refresh an existing identity.
src/routes/(console)/account/identities.svelte Adds a GitHub identity reconnect action and records the attempt before OAuth navigation.

Reviews (5): Last reviewed commit: "fix(billing): re-run the usage check whe..." | Re-trigger Greptile

Comment thread src/lib/stores/billing.ts Outdated
headerAlert.add() no-ops on an id it already holds, so adding with show:true
was a one-way door: once an organization reverified, the entry stayed visible
in the priority store while its component rendered nothing, and because it
outranks the alerts below it, they were blanked rather than shown. Both ids
now carry the current condition on every call.
Comment thread src/lib/stores/billing.ts
Comment on lines +493 to +502
importance: 11
});
headerAlert.updateShow('programMembershipRestricted', programMembershipRestricted);

headerAlert.add({
id: 'programMembershipWarning',
component: ProgramMembershipAlert,
show: programMembershipWarning,
importance: 9
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 Alert visibility remains stale

When the selected organization is reverified or transitions between warning and restricted states without changing its ID, the layout skips checkForUsageLimit, so these updateShow calls never receive the new state. The obsolete entry remains active in the priority store, suppressing lower-priority banners or continuing to show the warning instead of the access-restricted banner.

Knowledge Base Used: Console application shell

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/lib/stores/billing.ts
Line: 493-502

Comment:
**Alert visibility remains stale**

When the selected organization is reverified or transitions between warning and restricted states without changing its ID, the layout skips `checkForUsageLimit`, so these `updateShow` calls never receive the new state. The obsolete entry remains active in the priority store, suppressing lower-priority banners or continuing to show the warning instead of the access-restricted banner.

**Knowledge Base Used:** [Console application shell](https://app.greptile.com/appwrite/-/custom-context/knowledge-base/appwrite/console/-/docs/console-application-shell.md)

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Claude Code Fix in Codex

… gate

checkForUsageLimits returns early when the organization id is unchanged, so an
organization reverified or restricted mid-session never re-ran the checks — and
the banner it already had stayed put, outranking the alerts below it while
rendering nothing.

The sync is now its own exported function, called from the layout on every
organization change rather than only on a new id. checkForUsageLimit still
calls it, so the readOnly and delete-member paths are unchanged, and
activeHeaderAlert already recomputes from the store.
Comment thread src/routes/(console)/+layout.svelte Outdated
The unguarded sync updated the banner but threw away what it returned, while
readOnly stayed behind the organization-id gate. An organization restricted
mid-session therefore showed "access restricted" with its member, project and
resource controls still enabled — the banner and the write gates disagreeing,
which is worse than both being stale together.

It only ever raises readOnly, never clears it: the flag may already be true for
an unpaid invoice or a budget, and this check knows nothing about those.
Comment thread src/routes/(console)/+layout.svelte Outdated
Deciding readOnly from the membership sync alone could only ever be half right.
Raising it left a reverified organization locked; clearing it would have
unlocked one held for an unpaid invoice or a budget, which this check knows
nothing about.

checkForUsageLimit already owns that decision for every reason at once. The
layout gates it on the organization id, so it is re-run when status, remarks or
the unverified stamp change under a stable id, and left alone otherwise. First
sight of an organization still goes through the id-gated path, which runs the
rest of the billing checks with it.
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