尚未检测到更新。GitHub 发布版本后会显示在这里。
个人 GitHub 账号下公开仓库的所有者可以登录并验证所有权,自行管理介绍,无需管理员批准。
登录并生成验证文件,在 30 分钟内将其提交到此仓库的默认分支。其他分支或分叉仓库中的文件无效。然后在下方检查文件,即可无需管理员批准直接管理项目。请保留文件以维持管理权限。
如需登录,将前往登录页面。登录后请回到此处,再次点击所选的验证按钮。
在项目文件夹中运行 /nomorevibe verify,即可验证所有权并自行管理介绍。
一分钟完成验证制作者的介绍仅有上面的一句话。 更多详情请阅读仓库 README。 仓库 README ↗
正在加载完整 README。
查看完整 README ↗Educational dashboards and student management, on subscription, for teachers.
One platform hosts many teachers. Each teacher gets their own space — their students, courses, lessons, question bank, tests, assignments and reports — with nothing shared between them. Lumen sells that space as a managed service: a one-off setup, then a monthly fee that depends on how much of the day-to-day work Lumen carries and how many students the teacher has.
| Path | What it is |
|---|---|
index.html, features.html, contact.html | The public site. It quotes no prices: what Lumen costs is said on the call, and a student reads these pages too |
login.html, forgot-password.html, reset-password.html | Sign-in and password recovery |
join.html | Where a batch invite link lands — the one page a student uses before they have an account |
teacher/ | The teacher portal — the management system |
portal/ | The student portal |
admin/ | The Lumen console — teacher spaces, billing, leads |
api/ | Vercel serverless functions (anything needing the service-role key) |
api/_lib/ | Dependency-free helpers shared by the functions (unit-tested) |
css/site.css | The public site's design system |
css/portal.css | The design system shared by all three portals |
js/ | config.js (your Supabase values), ui.js, auth.js, shell.js, site.js |
supabase-setup.sql | The whole database: tables, row-level security, storage |
tests/ | Vitest suite over the API handlers, the shared libraries, and the pages |
There is no build step. The pages are static HTML that load Supabase from a
CDN; api/ is the only thing that runs on a server.
Create a Supabase project, open the SQL editor, and run
supabase-setup.sql in full. It creates every table, the row-level
security policies that separate one teacher from another, the storage
buckets, and the starting price list.
A project that already has an older supabase-setup.sql needs each
supabase-migration-vN.sql after it, in order. A fresh project does
not: the setup file already contains what they add. Every migration is
safe to run twice.
Fill in js/config.js with your project's URL and anon key
(Supabase → Project Settings → API):
window.LUMEN_CONFIG = {
SUPABASE_URL: 'https://your-project.supabase.co',
SUPABASE_ANON_KEY: 'eyJ…',
…
};
The anon key is meant to be public — it is in every page the browser
loads. What keeps one teacher's students out of another teacher's data is
row-level security, not the secrecy of that key. The service-role key
must never appear in js/.
SITE_URL is the address Lumen is handed out under. Links a teacher
gives somebody else — a batch invite pasted into WhatsApp, a student's
sign-in details — are built from it rather than from whatever URL the
teacher happens to be browsing. Without it, a teacher working on the
deployment's own hostname sends students
lumen-something-projects.vercel.app/join/HdnAqBzjFL, which wraps over
three lines in a chat app. Origin only, no trailing slash; leave it out
and links fall back to the current origin. Navigation inside the app
stays relative either way, so a preview deployment keeps browsing
itself.
Its server-side twin is the PUBLIC_URL environment variable, read
by siteUrlFor() in api/_lib/email.js for the links inside emails.
Set both, to the same site, or a welcome email and the page that sent it
will disagree about where Lumen lives.
One deliberate exception: the password-reset redirect in
forgot-password.html stays on location.origin. Supabase only
redirects to URLs on its own allow-list, and the one that always matches
is the origin the request came from.
Set three environment variables wherever you deploy (Vercel → Settings → Environment Variables):
| Variable | Where to find it |
|---|---|
SUPABASE_URL | Project Settings → API |
SUPABASE_ANON_KEY | Project Settings → API |
SUPABASE_SERVICE_ROLE_KEY | Project Settings → API (keep it secret) |
Mail is optional but needed for two things: the password handed to a new student, and the help requests students send through Lumi.
| Variable | What it does |
|---|---|
BREVO_API_KEY | Brevo → SMTP & API → API keys |
BREVO_SENDER_EMAIL | A sender address verified in Brevo, or it refuses |
BREVO_SENDER_NAME | Optional; defaults to "Lumen" |
SUPPORT_EMAIL | Where Lumi's help requests go; defaults to [email protected] |
Row-level security has no back door, so the very first account has to be made outside the app:
supabase-owner-setup.sql, put that email at the top, and run it
in the SQL editor. It sets both halves — the role claim the policies
read out of the JWT, and the profiles row the portal reads — and
prints them back so you can see they match./login.html. You land in /admin/.From there, "Open a space" creates a teacher, their sign-in and their subscription in one go.
There is no self-serve signup: a teacher cannot create their own space.
Every space is opened by Lumen from the console, after the call they book
on /contact.html.
js/config.js is served to every visitor, so its contents are public
whatever the repo's visibility — that is fine for the anon key, which is
designed for it, provided supabase-setup.sql has actually been run.
Without those policies the anon key reads everything. The service-role key
belongs only in the deployment's environment variables, never in js/,
and never in a commit.
Every teacher is one row in teachers, and every piece of content carries
that teacher's id. Isolation is enforced by row-level security reading two
claims out of the signed-in user's JWT:
app_metadata.role -- 'owner' | 'teacher' | 'assistant' | 'student'
app_metadata.teacher_id -- which space they belong to
Both are written by the server-side API, which holds the service-role key,
when the account is created. A signed-in user cannot change their own
tenant or promote themselves. Reading the claims from the token rather
than from profiles also keeps the policies free of the recursive profile
lookup that makes RLS on a profiles table so easy to get wrong.
The service-role client in api/ bypasses RLS, so every handler there
gets its tenant from tenantFor() in api/_lib/auth.js and checks any row
it was handed with assertTenant(). Those two functions are the whole
boundary; the tests in tests/api/auth-lib.test.js exist to keep them
honest.
Two things are enforced in the database rather than only in the pages:
open_at — cannot have its lessons or materials read at all.So the lock icons in the student portal are labels on a rule the database is already applying, not the rule itself.
The question bank has no student policy at all: every row in it says
which option is correct, and tests are built by copying rows out of it.
Students reach it only through POST /api/practice, which sends question
and option text with correct removed and marks each answer server-side.
It serves a question only from a released unit of a course the caller is
enrolled on, and practice is never written to test_attempts.
Practice shows the answer once a student has had their go, so
question_bank.practice_ok decides per question whether they may meet it
there at all. It defaults to true, and the teacher decides on the way
in — the tick in the question editor, or the one on the batch importer,
for a question being saved towards a paper that has not opened yet. They
flip it afterwards from the bank too — "Hold back" / "Allow practice" on
the card — and the flag is checked both when questions are served and
again when one is marked.
There is no student dashboard. My courses is where they land and where the work is — a dashboard in front of it was a page to click past.
That one page holds it all. It holds the units their teacher has opened, and under each unit the lessons, the handouts on each lesson, and the papers set on them. Units start collapsed — a term's worth open at once is a page nobody can find anything in.
A test is shown only where it was filed. One with no unit, or one on a unit that has not been released, does not appear on the student's page at all — setting the Unit and Lesson fields in the test builder is what puts a paper in front of them.
Lumi, the character in the corner, is on every student page and is
mounted by renderShell(). What a student writes to it goes through
POST /api/support, which emails it to SUPPORT_EMAIL (defaulting to
[email protected]) with the student as the reply-to, and
files it in support_requests (migration v4) for the teacher's
dashboard.
Neither half is allowed to sink the other: a space that has not run v4 still gets the email, a mail provider that is down still leaves the row, and the request only fails when both fail — which is the only case where what the student typed has gone nowhere.
There is deliberately no separate Lessons or Tests tab — /portal/lessons
and /portal/tests redirect to /portal/courses (see vercel.json), so
old links still land somewhere. A test only appears under a unit if the
teacher chose one in the test builder; the Unit field there defaults to
"Whole course".
A teacher does not teach one class of forty — they teach the same course
several times a week to different sets of students. A group is one of
those sittings: a name, the days it meets (0–6, matching JavaScript's
getDay()) and a start time.
A group belongs to a course, and which one a student attends lives on
their enrolment. That is what makes "Sara is in the Sunday group for
Biology and the Tuesday group for Chemistry" expressible. The foreign key
is on the pair (group_id, course_id), so an enrolment naming a group
from a different course is refused by the database, not just by the page.
The student list carries Course and Group as separate columns, one line each per enrolment and in the same order, so row two of one is row two of the other. They used to be a single "Biology · Sunday" badge, which is how a teacher says it but not how they read a list: the question this table is actually asked is who is not in a class yet, and that cannot be scanned for when the answer is the absence of half a badge. A student with no group on a course now says No group in yellow, and the group filter's "Not in a group" narrows to exactly those.
Groups are written under Students → Groups, and a student is put in one under Students → Courses & groups, which is also the only place an existing student's enrolments can be changed. Both are on the same page on purpose: a teacher who finds a student with no group should not have to leave the list they are working through to make the group. The modal carries its own course picker, opening on whatever the list is filtered to.
Deleting a group takes its students off that course with it. The
confirmation names them rather than counting them, and the enrolments
are deleted before the group is — once the group is gone, group_id is
null and nothing says who was in it. It is the enrolment that goes,
never the account: they keep their sign-in, their other courses and
every mark, and nothing in the database points at an enrolment row, so
enrolling them again restores exactly what they had.
The foreign key still says ON DELETE SET NULL (group_id), and that is
not a contradiction — it is the floor under every other way a group can
disappear. A group vanishing on its own must not quietly unenrol
anybody; the page unenrols them because a teacher asked it to, in front
of a dialog that says so.
Group names are ordered with compareNames() from js/ui.js, not by
the query's order('name'). Postgres compares by collation, which puts
"Group 10" before "Group 2" and a lower-case name after every
capitalised one — which a teacher scanning fifteen groups reads as no
order at all.
Adding a class of thirty one form at a time means typing thirty names and thirty phone numbers the teacher does not have in front of them. Instead they make one link per batch under Students → Invite a batch, send it once to that class's WhatsApp group, and each student fills their own details in.
A link can be edited after it is sent — its course, its group, its label, its limit. The token is never rewritten, so the URL already sitting in a class WhatsApp group keeps working; what changes is where the next student who uses it lands. The ones who already registered keep the enrolment they were given, because that is where they actually are, and the modal says so before the change rather than after.
The course and the group are validated as a pair even when only one
of them was sent. "Keep the group, change the course" is exactly how a
link ends up enrolling students into a class that meets for another
subject; the composite foreign key on (group_id, course_id) refuses it
too, but the handler says which half was wrong.
A link is tied to one course and one group, so every student who uses it lands in the right class without being asked to pick. It stays usable until the teacher closes it, it expires, or it hits the limit they set.
The point of the link is that each person uses it once. Three things enforce that, in order of how much they are trusted:
api/join.js checks, before writing, whether that email or that phone
number has already registered — and whether it already belongs to a
student the teacher added by hand. That is what produces a sentence
the student can act on.supabase-migration-v11.sql decide it. Two
taps on a slow connection are two requests that both got past the
check above before either had written a row; only an index settles
that race, and the loser is told the same thing a moment later.A phone number is matched on its last 9 digits, so +20 101 234 5678
and 01012345678 are one person. phoneKey() in api/_lib/util.js and
the generated phone_key column in the migration must agree about that;
a test in tests/api/util-lib.test.js reads the SQL and checks they do.
Both keys are per teacher. The same student may sit in two teachers' spaces — they are different schools — but only once in either.
A matching name is not a block. Two real students called Mohamed Ali is ordinary, and refusing the second would turn a common name into a locked door, so the registration is flagged and the teacher decides.
A parent's mobile and email are required on every new student, both
on the batch-invite form a student fills in and on the teacher's own
Add student. A teacher who needs to reach
somebody's family about attendance or a result cannot do it through a
teenager's phone, and the moment they need it is never the moment to
start asking for it. api/result-email.js sends a finished test's mark
to exactly that address.
The parent's number is deliberately not part of the once-per-person check. Siblings share a parent, and matching on it would refuse the second child in a family as a duplicate of the first.
The rule lives in api/students.js and api/join.js rather than in the
createStudentAccount() both call, because approving a registration
taken before this was asked for has to keep working — those rows have
no parent details, and refusing them would strand a queue the teacher
cannot empty any other way. A test reads the shared file to check the
rule has not migrated into it.
A student who fills the form in gets their account in the same request, and their email and password on the next screen. There is no queue and nothing to approve.
api/join.js calls the same createStudentAccount() in
api/_lib/students.js that Add student does — same plan check, same
one-time password, same welcome email — so a student who registers
themselves is the same student, made the same way.
What stands between a stranger and an account is the link: closed,
expired, used past max_uses or flooded are all refused before anything
is written, and every one of those refusals reads the same so that
probing tokens teaches nothing. Then the once-per-person rules above,
then the teacher's plan.
The registration row is still written, before the account rather than
instead of it. It is the lock — the unique index on it is the only thing
that stops two taps on a slow connection becoming two accounts, which no
check written in JavaScript can do — and afterwards it is the record of
who came in through which link. It is simply born approved, with
reviewed_by null because nobody reviewed it. If the account cannot be
made, the row is deleted again: it was a lock, not a record of something
that happened, and leaving it would bar that student from ever retrying.
Why the account could not be made is not the student's business. A plan that is full, a subscription past due, a space with no subscription at all — those messages are written for the teacher and name their billing. A stranger holding a link gets one neutral 503 telling them to go and tell their teacher, and the real reason goes to the log.
The password comes back to the page as well as going out by email. The student is standing there with a phone at that moment, which is the one time they are certainly reachable; a mail provider having a bad afternoon must not be the difference between having an account and not.
The teacher's Waiting for you queue and its approve / turn-down
actions in api/invites.js are still there for anything registered
before this changed. Turning one of those down frees that email and
number to register again, which is the repair for a student who mistyped
their address: the partial unique indexes exclude rejected rows on
purpose.
| Role | Signs in at | Can do |
|---|---|---|
owner | /admin/ | Lumen staff: open spaces, set plans, raise invoices, read leads |
teacher | /teacher/ | Everything inside their own space |
assistant | /teacher/ | Only the parts of their teacher's space they were given |
student | /portal/courses | Their own courses, tests, assignments and results |
An assistant's permissions live in profiles.staff_perms. Hiding a nav
link is presentation — the page's own requireAuth(…, { perm }) and the
API's requirePerm() are what actually stop them.
The price list lives in the plans table and the marketing site reads it
live, so changing a price is a database edit rather than an HTML one. The
service sheet quotes two things and both are in there: the packages
(what Lumen does for you) and the student tiers (what it costs at each
class size). plans.listing says which a row is.
A teacher's agreed limit and fee are copied onto their subscription
rather than read through plans, so re-pricing the catalogue never
silently changes what an existing teacher agreed to. The limit is checked
server-side in api/_lib/subscription.js before a student is created or
reactivated — the browser is where a teacher can least be expected to be
honest with themselves about how many students they have.
A late invoice pauses new accounts. It never cuts off students who already have one: the teacher's payment is not the class's problem.
| Endpoint | Who may call it | What it does |
|---|---|---|
POST /api/students | teacher, assistant* | Create, reset password, pause, delete |
POST /api/invites | teacher, assistant* | Make, edit and close batch invite links; approve or turn down what they bring in |
POST /api/join | anyone | Read what an invite link is for, and register once against it |
POST /api/staff | teacher | Add, re-permission, suspend, remove assistants |
POST /api/save-test | teacher, assistant* | Write a test and replace its questions in one call |
POST /api/questions | teacher, assistant* | Parse and import a pasted batch of questions |
POST /api/practice | student | Serve bank questions with the answers stripped, and mark one at a time |
POST /api/support | anyone signed in | Email a help request written to Lumi, and file it when the table is there |
POST /api/result-email | student | Email a finished test's mark to the parent on file, once |
POST /api/teachers | owner | Open, edit and delete spaces, set plans, raise and settle invoices |
* with the matching permission.
api/join.js is the one endpoint with no signed-in caller — a student
registering does not have an account yet, which is the point of them
being there. Everything else authenticates first, which is the whole
reason a teacher cannot open their own space.
Because that one is open, it reads nothing about where a registration goes from the request body: the tenant, the course and the group all come off the invite row its token resolves to. It creates no auth user and no profile, and it gives back only what the holder of the link already knows — the space name, the course, the class time.
Anything that needs the service-role key lives here: creating auth users, setting passwords, deleting accounts, and writing rows that must land together. Everything else goes straight from the browser through RLS.
A question carries four optional pointers — course, unit, lesson, and
section — and every one is ON DELETE SET NULL. That is the important
part: a question outlives the unit it was written for. It is still a
good question when next year's units are rebuilt, and cascading would
quietly empty a bank a teacher spent a term filling.
A lesson names its unit, so both APIs refuse a pair that disagrees: a lesson without the unit it belongs to, or a lesson from a different unit than the one chosen with it. Otherwise a Unit 1 test could be filed under a Unit 4 lesson and appear in two places in the student portal.
Course, unit and lesson say where in the course a test sits. They do
not say what it asks. A language teacher sets one paper on vocabulary
and another on grammar for the very same lesson, and needs to tell them
apart in a list of forty tests. test_sections is that label.
Each teacher writes their own list rather than choosing from ours. Lumen is sold to whoever teaches: "Vocabulary" and "Grammar" are the right two for an English teacher and meaningless to a chemistry one, and a fixed set would be a migration every time a teacher wanted a section we had not thought of. Nothing is seeded — a space that opens with somebody else's words already in it reads as a bug. Teachers add their own under Settings → Test sections.
Names are unique per teacher on lower(name), so "grammar" typed in a
hurry is caught as the section that already exists. Removing a section
nulls the label on the rows that point at it; the dialog says how many
tests and questions that is, and that none of them are deleted.
A question in the bank carries its own section, set where the question is, independently of where it sits. A question can easily know where it sits and not what it asks, so the two are never filled in together.
A test is built from the bank, by picking questions out of it, and
never back into it. Saving a test writes test_questions and nothing
else.
It did not always work that way. Saving a test used to file every question on it into the bank, tagged with the test's course, unit and lesson — which meant a fifty-question paper pasted out of a Word file put fifty rows into a bank nobody asked to have fifty rows in, and a correction made on one paper quietly rewrote bank rows other tests had been built from. The bank is a collection kept on purpose. It is filled on its own page, by hand or by its batch importer, and that is the only thing that writes to it.
Two things still guard it, because the bank page writes straight from the browser and an importer can be run twice:
question_bank.text_key — md5 of the question text, generated in
Postgres, with a unique index on (teacher_id, text_key). It is
the index that decides, not any check made before writing: the bank
page writes to the table without passing through api/ at all, so
two imports in the same second both pass a read-and-compare before
either has written.public.normalize_question_text() — non-breaking spaces to ordinary
ones, runs of whitespace collapsed, ends trimmed. Without it a single
invisible character files a second copy of a question a teacher reads
as identical, which is not hypothetical: these are pasted out of Word.
normalizeQuestionText() in api/_lib/questions.js is the same rule
in JavaScript, and a test reads the SQL to check the two still agree.
Case is deliberately left alone — case folding is the one operation
the two languages disagree about across locales, and a dedup that is
wrong is worse than one that is narrow.Questions can still be pasted straight onto a test without going near the bank, which is what keeps the work in the right order for a teacher who has a paper in a Word file and no interest in filing it. Those questions live on that test alone.
A test also holds its own copies of its questions, not pointers into the bank, so editing or deleting a bank question never changes a paper students have already sat.
The way in is the bank's own filters. Narrow to a course, a unit, a lesson, a section, a difficulty; tick Select all showing; press Create a test from these. The test builder opens with those questions already on it and with the course, unit, lesson and section pre-filled from the filters they were picked under — the teacher adds a title and a window and saves.
The selection deliberately survives a filter change, because a paper is usually built out of more than one lesson: take all of lesson 1, narrow to lesson 2, take that too. The bar says how many of the picks are no longer on screen so the count is never a surprise.
The handover is a sessionStorage key (lumen_test_seed) holding the
ids and the filters, not a query string — "select all" on a full bank is
hundreds of ids, which no browser will carry in a URL. It is read once
and removed, so refreshing the builder gives a blank one rather than
silently rebuilding a paper the teacher has moved on from. The builder
then fetches those questions by id rather than looking them up in
the copy of the bank it holds for the picker, so a selection cannot
quietly lose a question for a reason nobody can see. A test caps at 300
questions in api/save-test.js, and the bank says so before the click
rather than after it.
Delete selected is the same selection pointed the other way, for a
bank that needs clearing out — the one filled by the old behaviour where
saving a test filed all of its questions. It cannot reach a test:
test_questions holds its own copies and nothing references
question_bank.
The paste box is parsed by /api/questions with action: 'parse', the
same endpoint the bank's own importer previews with, so what is
understood in the two places cannot drift apart. It reads multiple
choice: options bulleted or lettered, the answer marked with a *,
a (correct), or an Answer: B line.
A teacher can fix most of their own details, but not the address they sign in with — their settings page tells them to contact Lumen, and the console's Edit on a space is where Lumen does it.
The address lives in three places that have to move together: auth.users
is what they sign in with, profiles.email is what every portal list
reads, and teachers.contact_email is what Lumen writes to. The sign-in
is changed first because it is the one write that can be refused for a
reason no check here can see; if the profile will not follow it, the
sign-in is put back and nothing else is touched.
The password is never reissued — a teacher whose address was corrected should not be locked out of a space they were already using. The new address is written to, which also proves it was typed correctly.
The slug can be changed here too. It is the one field with consequences outside the database: it names the space in links and in the sign-in hint its students are given, and it is the word typed back to delete a space. Links anyone saved to the old one stop working, so the console warns before saving.
The one irreversible action in the product. It removes the teacher, every account in their space, all their content and every result their students recorded, and it is deliberately awkward: the console shows the counts first and the slug has to be typed back before anything happens.
Two things it does that the database will not do on its own:
teachers row cascades
through the content, but never reaches auth.users — those rows hang
off profiles the other way round. Left behind they would hold their
email addresses forever, so nobody in that space could be re-created.The deletion is logged with a null teacher_id on purpose — a log line
pointing at the deleted tenant would cascade away with it, and this is
the event most worth still having afterwards. Nothing in the console
displays those lines yet; read them from activity_log in SQL.
Pausing a space is the reversible alternative and is what the dialog pushes you towards: it hides the portal from everyone in the space and deletes nothing.
npm install
npm test # vitest run
npm run test:watch
The suite covers every api/ handler (auth and role checks, tenant
boundaries, validation, and the rollback paths) against an in-memory
Supabase stand-in — no network, no real database — plus the shared
libraries, plus static checks over every page: inline JavaScript that
parses, links that go somewhere, script tags in a working order, no
hard-coded keys, and every guarded page actually guarding itself.
CI runs it on every push and pull request (.github/workflows/ci.yml).
Vercel serves the static files and runs api/ with no configuration
beyond vercel.json and the three environment variables above. Any host
that serves static files and Node functions will do the same.