For Developers
The hub as a system: what it is made of, where every record lives, what each endpoint takes and returns, and the exact shape of a hand-in, for whoever connects Asha or anything else to it. Kept true by the build: the schema below is the generated one.
What It Is Made Of
- A static site.
tools/build.js, Node with no packages, writespublic/fromdata/,content/,prompts/andsite/static/. Cloudflare Pages builds it on every push tomain; a branch gets its own preview address. - Pages Functions in
functions/api/, ES modules, one file per endpoint, sharinglib/auth.js(sign-in, cookie, roles) andlib/career-map.js. - One D1 database (SQLite), bound as
DB. Its shape is the numbered files infunctions/migrations/;functions/schema.sqlis generated from them and checked on every build. - No AI, no mail, no queue. Prompts go to the learner's own AI as text; the support form opens the learner's mail app.
Sign-in and Roles
- Google Identity Services in the browser gives an ID token;
POST /api/auth/googleverifies it against Google's keys and sets a signed, HttpOnly session cookie (HMAC withSESSION_SECRET). Every function reads the cookie withcurrentUser(). - Roles:
admin, ml-captain, ml-guide, ml-coach, ml-evaluator, bc-trainer, bc-sa, bc-evaluator. Two floors come from variables:ADMIN_EMAILSare admins; any address onTRAINER_DOMAINor inTRAINER_EMAILSisbc-trainer. The rest live in therolestable. - Every protected endpoint fails closed: 401 without a session, 403 without the role, 503 without the database. All SQL is bound; all browser HTML is escaped.
- The person's key everywhere is the email they sign in with. No table joins across people.
Endpoints
| Method | Path | Who | Takes | Returns |
|---|---|---|---|---|
| GET | /api/me | anyone | none; ?view=1 counts a page view | { ok, configured, clientId, user: { email, name, picture, roles[], sections[] } | null }; sections are the staff pages the roles open, { name, path, what } |
| POST | /api/auth/google | anyone | { credential }, a Google ID token | sets the session cookie; { ok, user } |
| POST | /api/auth/logout | signed in | none | clears the cookie |
| GET | /api/portal/roster | campus staff (an address on portal.domains) | none | { ok, updated, sources, groups: [{ name, roles: [{ role, who, note, source }] }] } from data/roster.json: who holds which role, as the papers name them; 401 or 403 to anyone else, and never in a page |
| GET | /api/portal/me | anyone | none | { ok, configured, clientId, orgs[], user: { email, name, picture, roles[], roleLabels[], sections[], org, staff } | null }: the Dira Portal's sign-in state; the session cookie is shared across learninglions.org |
| POST | /api/submit | anyone; signed in is verified | { name, lionId, level, session, link, note, items: { piece: value } } | { ok, message }; the row is the learner's own only when signed in |
| GET | /api/mine | signed in | none | { ok, user: { email, name, level, cohort, startDate }, rows: [submission] }, verified rows only |
| POST | /api/mine | signed in | { level, cohort, startDate } | saves to users |
| GET | /api/handins/<session id>.json | anyone (a static file) | none | { id, code, html, pieces[], worksheet }: what the session asks for, and the fill-in page when there is one |
| GET | /worksheets/<session id>.json | anyone (a static file) | none | the session's worksheets as data: sheets of blocks, each answer cell, blank and box a field with an id and a label |
| GET | /api/worksheet?session=<id> | signed in | none | { ok, row: { id, session, version, answers, status, submissionId, updatedAt, submittedAt } | null }, the person's own |
| PUT | /api/worksheet?session=<id> | signed in | { action: "save"|"submit"|"reopen", answers: { field id: text | true }, version } | saves the draft; hands it in, filing a submissions row whose worksheet_id points back; or reopens it |
| GET | /api/worksheet/<id> | the owner; evaluation and submissions roles for the level; admin | none | { ok, row, owner: { name, email }, submission: { status, stars, feedback } }: one filled-in worksheet |
| GET | /lessons/<session id>.json | anyone (a static file) | none | the lesson as data: parts of moves (read, look, do, check, answer, remember), from content/lessons |
| GET | /slides/<session id>.json | anyone (a static file) | none | the Session Presentation as slides: title, kicker, body lines, the trainer's notes (say, do, ask, watch, time), the image, and which sheets each slide opens |
| GET | /api/walk?kind=take|teach|learn|card[&session=<id>] | signed in; teach: bc-trainer, admin | none | { ok, row | rows }: the person's own walk (the stage reached, the ticks, the notes; for a card, its data: card-<id> a card, sign-<id> a signed document); &team=1 every trainer's teach rows, for the Studio; kind=card&all=1&prefix=sign- every signature, for admins |
| PUT | /api/walk?kind=take|teach|learn|card&session=<id> | signed in; teach: bc-trainer, admin | { slide, count, ticks, notes, status }; a card { data, status } | saves the walk, or a card or a signature; the worksheet answers go through /api/worksheet |
| GET | /cards.json | anyone (a static file) | none | the cards a learner builds on My Darasa (content/cards.json) and the titles of the Day-One Pack documents |
| GET | /api/teach[?session=<id>] | bc-trainer, admin | none | the sessions with a TOT Deck, or one session: the deck as slides with its notes, the run sheet's segments as a timeline, the trainer files as Drive links |
| GET | /api/submissions | bc-trainer, ml-captain, ml-guide, admin | ?session=&level=&q=&limit= | { ok, rows: [submission] } for the levels the roles cover |
| GET | /api/evaluate | ml-evaluator (own), bc-trainer, bc-evaluator, admin | ?done=1 | the queue, { ok, rows } |
| POST | /api/evaluate/<id> | the assignee, the BootCamp team, admin | { status: "done"|"new", feedback, stars: 1|2|3|null } | { ok, id, status, stars } |
| GET | /api/plan | bc-trainer, admin | none | { ok, team, plan | null, previous, updatedAt, updatedBy }: the trainer team's plan of the BootCamp; null means the kits' placements apply |
| PUT | /api/plan | bc-trainer, admin | { plan: { v: 2, capacity: { taught, sgl, evening }, pools: { "week-day-block": [piece ids] }, tray: [ids], hours: { id: hours }, pieces: { id: { session, kind } } } } | saves the team's plan (one row, plans.team = bootcamp), keeping the version before for one undo; { ok, plan, previous, updatedAt, updatedBy } |
| DELETE | /api/plan | bc-trainer, admin | none | resets the team's plan to the kits' placements (removes the row) |
| POST | /api/plan/undo | bc-trainer, admin | none | puts the version before the last save back, once; 404 when there is none |
| GET | /plan-base.json | anyone (a static file) | none | the base calendar from the kits: { capacity, sessions[], pieces: [{ id, session, kind: taught|sgl, hours, briefing }], pools: { "week-day-block": [piece ids] }, tray }; the blocks are taught, sgl and evening |
| GET | /api/cohorts | bc-trainer, admin | none | { ok, rows: [{ id, name, startDate, endDate, holidays: [{ date, name }], active }] } |
| POST | /api/cohorts | bc-trainer, admin | { id?, name, startDate (a Monday), holidays, active } | makes or changes a cohort; active makes it the one /calendar/ shows; { ok, row } |
| DELETE | /api/cohorts/<id> | bc-trainer, admin | none | removes the cohort |
| GET | /api/calendar | signed in | ?cohort=<id>, else the active cohort | { ok, cohort, track, capacity, days: [{ date, week, day, holiday, pools: { taught, sgl, evening } }], overflow, tray }: the plan laid on the cohort's dates; each piece { id, session, code, title, url, track, kind, hours, briefing, from }; track is the viewer's own |
| GET | /api/files | crew, studio, desk roles | ?level=&audience=trainer|scripts | the staff file lists as Drive links |
| GET | /api/admin/users | admin | ?q=&days= | everyone signed in, with roles and counts |
| POST | /api/admin/roles | admin | { email, roles[] } | replaces the person's given roles |
| POST | /api/admin/view-as | admin | { email } or { role } or {} to stop | sets the view cookie; every write is refused while it is set (functions/_middleware.js) |
| GET | /api/admin/log | admin | ?kind=&days=&limit= | { ok, rows: [{ at, kind, who, what }] }, newest first |
| any | /api/career-map/* | signed in; own rows only | see the route list below | the Career Map, the CV material and Finding Work |
The Career Map Routes, Under /api/career-Map/
| Route | What |
|---|---|
map | GET the whole map; DELETE purges the person's rows |
map/who, map/plan, map/audit, map/intro | PUT one stage as JSON |
map/evidence, map/entries, map/actions | POST adds, PUT /<id> edits, DELETE /<id> removes |
map/export.docx | GET the map as a Word file |
work/leads, work/outreach | POST, PUT /<id>, DELETE /<id> |
work/prompt/intro, work/prompt/scout | POST: the prompt text for the person's own AI |
work/export.docx | GET Finding Work as a Word file |
checkins | POST a check-in; DELETE /<id>; PUT reminders |
cv | POST the draft; PUT improved; POST undo; versions/<id> PUT, DELETE, undo, docx |
answers, prefs | PUT the questionnaire and the preferences |
ai/prompt/<kind>, ai/file/instructions, ai/file/cv, ai/paste/<kind> | the own-AI round trip: the prompt and files out, the answer pasted back |
A Hand-In, as Stored
One row of submissions. This is what Asha will receive, field for field, once the hub writes to it. items is JSON text keyed by the piece names from the session's kit (output.items); note carries the same pieces as readable lines. verified is 1 only when the learner was signed in. stars is the programme's score: 3 exceeds, 2 meets, 1 below and resubmit.
{
"id": 42,
"created": "2026-10-24T14:03:11.000Z",
"name": "Amina Ekiru",
"lion_id": "amina.ekiru@learninglions.org",
"level": "bootcamp",
"session": "bootcamp-v3-fs4",
"link": "https://drive.google.com/file/d/.../view",
"note": "the restore pair: https://drive.google.com/...\nthe one-minute find: witnessed by Peter",
"items": "{\"the BootCamp tree on the trainee's own machine, built to the pattern on the board\": \"https://drive.google.com/...\", \"the restore pair\": \"https://drive.google.com/...\", \"the one-minute find\": \"witnessed by Peter\"}",
"verified": 1,
"assigned_to": null,
"assigned_at": null,
"status": "done",
"evaluated_by": "trainer@learninglions.org",
"evaluated_at": "2026-10-25T09:40:00.000Z",
"stars": 2,
"feedback": "Tree and copy present; one naming slip. Two stars."
}
Ids and Conventions
- Session ids are
<level>-<version>-<code>, lower case:bootcamp-v3-fs4,mobile-leap-v2-do3. They are stable; a renamed session keeps its id and gains an alias. The full list is/sessions.json. - Levels:
mobile-leap,bootcamp,advanced-track; the Career Map addsgraduateandearlier. - Times are ISO 8601 in UTC, as text. Dates (a start date, a check-in) are
YYYY-MM-DD. - JSON columns end in
_json(the Career Map) or are named for what they hold (items); they are text and parsed by the function that owns them. - Every answer is JSON with
ok: trueorok: false, error, and a matching HTTP status. - What Asha gets, planned: one call per evaluated hand-in with the row above plus the session code and title, authenticated with a token Asha issues, retried until Asha answers 2xx. Not built until the token exists.
The Data Files the Build Reads
data/sessions.json: the manifest from the Drive tree (npm run scan): levels, modules, sessions with code, title, url, placement (week, day, slot), aliases, assets by audience. Committed.data/handins.json: per BootCamp session, from the live Word kits (npm run kits: the Take-Home Card for the pieces, the Evaluation Sheet for the criteria; the kit scripts as fallback): outputTitle, output (lead, items), submission, criteria, grading, gradingNote, source, updated, fromDocx.data/worksheets.json: per BootCamp session, from the live Word kits (npm run kits; the kit scripts as fallback): the sheets as blocks (heading, sub, label, note, p with parts, blank, ticks, form, grid, list, tree), each field{ id, kind: text | output | blank | check | select, label, options }; served as/worksheets/<session id>.json. The receipt (FS5's Director Log) is added to every definition bylib/worksheets.jsaddReceipt(). Answers are{ field id: text | true }on theworksheetstable; a hand-in is asubmissionsrow withworksheet_id; the evaluator's marks aresubmissions.evaluation_json,{ sheets: [{ id, title, met, note }], lines: [{ text, met }] }.data/plan-hours.json: per BootCamp session, from the kit scripts (npm run plan): the workbook block's taught, sglLab, overtime and total hours. The build turns the placements and these hours into/plan-base.json, the base calendar: every session's pieces (taught, and self-guided from SGL/Lab and Overtime) in the three blocks of a day. The trainers' plan (/studio/plan/) is oneplansrow laid over it; a cohort calendar (/calendar/) lays the result onto dates from acohortsrow. Never written back to the kits, the Eight-Week Matrix or the Google calendar.data/levels.json: the three levels, hand-written.data/career-map.json: the Career Map's words, stages, sittings per level, tracks, blocks, the CV questionnaire.data/drive-ids.json: the Drive file id for every file in the manifest, so session pages link to Drive.hub.config.json: the site name, addresses, the publish policy, the AI cards, the Safety and Welfare people and numbers, the kit script folders.
The Database
Generated from 14 migrations: 001-hub.sql, 002-roles.sql, 003-my-hub.sql, 004-handins.sql, 005-career-map.sql, 006-verified.sql, 007-guided.sql, 008-worksheets.sql, 009-plans.sql, 010-evaluation.sql, 011-track.sql, 012-cohorts.sql, 013-google-calendar.sql, 014-walks.sql. A fresh database takes the whole file; an existing one takes the migrations it has not had, in order.
-- The hub's D1 schema, GENERATED by tools/schema.js from functions/migrations/ (001-hub.sql, 002-roles.sql, 003-my-hub.sql, 004-handins.sql, 005-career-map.sql, 006-verified.sql, 007-guided.sql, 008-worksheets.sql, 009-plans.sql, 010-evaluation.sql, 011-track.sql, 012-cohorts.sql, 013-google-calendar.sql, 014-walks.sql).
-- Do not edit: change a migration, or add the next numbered one, then run `npm run schema`.
-- Paste this whole file into a fresh database (the D1 console takes one statement per Execute, or
-- wrangler d1 execute learninglions-hub --remote --file functions/schema.sql).
-- An existing database gets only the migrations it has not had yet, in order. Column comments and
-- the reasons are in the migration files.
CREATE TABLE IF NOT EXISTS submissions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created TEXT NOT NULL,
name TEXT NOT NULL,
lion_id TEXT, -- the learner's email when signed in, else what they typed
level TEXT,
session TEXT NOT NULL, -- a session id from data/sessions.json
link TEXT,
note TEXT
, assigned_to TEXT, assigned_at TEXT, status TEXT NOT NULL DEFAULT 'new', evaluated_by TEXT, evaluated_at TEXT, feedback TEXT, items TEXT, verified INTEGER NOT NULL DEFAULT 0, stars INTEGER, worksheet_id INTEGER, evaluation_json TEXT);
CREATE INDEX IF NOT EXISTS submissions_session ON submissions (session, created);
CREATE INDEX IF NOT EXISTS submissions_assigned ON submissions (assigned_to, status);
CREATE TABLE IF NOT EXISTS users (
email TEXT PRIMARY KEY,
name TEXT,
picture TEXT,
first_seen TEXT NOT NULL,
last_seen TEXT NOT NULL,
logins INTEGER NOT NULL DEFAULT 0,
views INTEGER NOT NULL DEFAULT 0
, level TEXT, cohort TEXT, start_date TEXT, track TEXT);
CREATE TABLE IF NOT EXISTS logins (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL,
at TEXT NOT NULL,
agent TEXT
);
CREATE INDEX IF NOT EXISTS logins_email ON logins (email, at);
CREATE TABLE IF NOT EXISTS roles (
email TEXT NOT NULL,
role TEXT NOT NULL,
given_by TEXT,
given_at TEXT,
PRIMARY KEY (email, role)
);
CREATE TABLE IF NOT EXISTS cm_map (
email TEXT PRIMARY KEY, -- level and cohort live on users, shared with My Hub
who_json TEXT NOT NULL DEFAULT '{}', -- traits self-check, strength, watch-out, EI goals, the sentence
plan_json TEXT NOT NULL DEFAULT '{}', -- strongest track, direction, three steps with cost and date, backup
plan_history_json TEXT NOT NULL DEFAULT '[]', -- earlier plans, newest first
audit_json TEXT NOT NULL DEFAULT '{}', -- what a search shows, the three fixes
intro_json TEXT NOT NULL DEFAULT '{}', -- the two-line introduction request
reminders_on INTEGER NOT NULL DEFAULT 1,
updated_at TEXT
);
CREATE TABLE IF NOT EXISTS cm_evidence (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL,
on_date TEXT NOT NULL DEFAULT '',
what TEXT NOT NULL,
link TEXT NOT NULL DEFAULT '',
block TEXT NOT NULL DEFAULT '', -- the session or place it came from (DO3, X1.7, a client, a job)
created_at TEXT
);
CREATE INDEX IF NOT EXISTS cm_evidence_email ON cm_evidence (email, on_date);
CREATE TABLE IF NOT EXISTS cm_entries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL,
level TEXT NOT NULL DEFAULT '',
track TEXT NOT NULL,
made TEXT NOT NULL DEFAULT '',
easy_hard TEXT NOT NULL DEFAULT '',
pull TEXT NOT NULL DEFAULT '',
direction TEXT NOT NULL DEFAULT '', -- Pursue | Stay curious | Not my path
action TEXT NOT NULL DEFAULT '',
action_date TEXT NOT NULL DEFAULT '',
created_at TEXT,
updated_at TEXT
);
CREATE INDEX IF NOT EXISTS cm_entries_email ON cm_entries (email, id);
CREATE TABLE IF NOT EXISTS cm_actions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL,
text TEXT NOT NULL,
due TEXT NOT NULL DEFAULT '',
earning INTEGER NOT NULL DEFAULT 0,
done_at TEXT,
created_at TEXT
);
CREATE INDEX IF NOT EXISTS cm_actions_email ON cm_actions (email, done_at, due);
CREATE TABLE IF NOT EXISTS cm_leads (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL,
col TEXT NOT NULL DEFAULT 'local', -- local | national | online | ai | any
lead TEXT NOT NULL,
offer TEXT NOT NULL DEFAULT '',
route TEXT NOT NULL DEFAULT '',
fit TEXT NOT NULL DEFAULT '', -- the honest fit note: what it pays, what it requires
link TEXT NOT NULL DEFAULT '',
created_at TEXT,
updated_at TEXT
);
CREATE INDEX IF NOT EXISTS cm_leads_email ON cm_leads (email, col, id);
CREATE TABLE IF NOT EXISTS cm_outreach (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL,
lead_id INTEGER,
kind TEXT NOT NULL DEFAULT 'intro', -- intro | application | follow-up
to_name TEXT NOT NULL DEFAULT '', -- first name only
channel TEXT NOT NULL DEFAULT '',
message TEXT NOT NULL DEFAULT '',
sent_on TEXT NOT NULL DEFAULT '',
reply TEXT NOT NULL DEFAULT 'none', -- none | yes | no | silence
reply_note TEXT NOT NULL DEFAULT '',
next_on TEXT NOT NULL DEFAULT '',
created_at TEXT
);
CREATE INDEX IF NOT EXISTS cm_outreach_email ON cm_outreach (email, sent_on);
CREATE TABLE IF NOT EXISTS cm_checkins (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL,
on_date TEXT NOT NULL,
status TEXT NOT NULL, -- job-ft | job-pt | business | freelance | study | looking | other
employer TEXT NOT NULL DEFAULT '',
role TEXT NOT NULL DEFAULT '',
town TEXT NOT NULL DEFAULT '',
hours TEXT NOT NULL DEFAULT '',
gigs_3m INTEGER NOT NULL DEFAULT 0,
platforms TEXT NOT NULL DEFAULT '',
income_band TEXT NOT NULL DEFAULT '', -- a band, never a figure; optional
how_found TEXT NOT NULL DEFAULT '',
note TEXT NOT NULL DEFAULT '',
consent INTEGER NOT NULL DEFAULT 0, -- Learning Lions may use this, without the name, in its reports
created_at TEXT
);
CREATE INDEX IF NOT EXISTS cm_checkins_email ON cm_checkins (email, on_date);
CREATE TABLE IF NOT EXISTS cm_profiles (
email TEXT PRIMARY KEY,
cv_text TEXT,
cv_filename TEXT,
cv_uploaded_at TEXT,
answers_json TEXT NOT NULL DEFAULT '{}',
prefs_json TEXT NOT NULL DEFAULT '{}',
analysis_json TEXT,
improved_cv TEXT,
previous_cv TEXT, -- for Undo
game_plan TEXT,
last_change TEXT,
cv_version INTEGER NOT NULL DEFAULT 1,
updated_at TEXT
);
CREATE TABLE IF NOT EXISTS cm_versions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL,
name TEXT NOT NULL,
brief TEXT NOT NULL DEFAULT '',
posting TEXT NOT NULL DEFAULT '',
markdown TEXT,
previous_markdown TEXT,
last_change TEXT,
version INTEGER NOT NULL DEFAULT 1,
created_at TEXT,
updated_at TEXT
);
CREATE INDEX IF NOT EXISTS cm_versions_email ON cm_versions (email, id);
CREATE TABLE IF NOT EXISTS worksheets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL, -- the signed-in learner
session TEXT NOT NULL, -- a session id from data/sessions.json
version TEXT, -- the kit version the answers were typed against
answers_json TEXT NOT NULL DEFAULT '{}', -- { field id: text | true }
status TEXT NOT NULL DEFAULT 'draft', -- draft | submitted
submission_id INTEGER, -- the submissions row of the latest hand-in
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
submitted_at TEXT
);
CREATE UNIQUE INDEX worksheets_owner ON worksheets (email, session);
CREATE TABLE IF NOT EXISTS plans (
team TEXT PRIMARY KEY, -- 'bootcamp': the BootCamp trainer team
plan_json TEXT NOT NULL, -- { v, capacity: { am, pm }, pools: { "week-day-slot": [session ids] }, tray: [ids], hours: { id: hours } }
previous_json TEXT, -- the plan before the last save, for one undo
updated_at TEXT NOT NULL,
updated_by TEXT NOT NULL -- the email of whoever saved last
);
CREATE TABLE IF NOT EXISTS cohorts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL, -- "Cohort 1, Lodwar"
level TEXT NOT NULL DEFAULT 'bootcamp',
start_date TEXT NOT NULL, -- the Monday the cohort starts, YYYY-MM-DD
holidays_json TEXT NOT NULL DEFAULT '[]', -- [{ "date": "2026-10-20", "name": "Mashujaa Day" }]
active INTEGER NOT NULL DEFAULT 0, -- 1 on the one cohort /calendar/ shows by default
created_at TEXT NOT NULL,
created_by TEXT NOT NULL,
updated_at TEXT NOT NULL
, gcal_id TEXT, gcal_name TEXT, gcal_synced_at TEXT);
CREATE TABLE IF NOT EXISTS walks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL, -- the signed-in person
session TEXT NOT NULL, -- a session id from data/sessions.json
kind TEXT NOT NULL, -- take | teach
slide INTEGER NOT NULL DEFAULT 1, -- the stage reached, 1-based (the slides, then the sheets and the hand-in)
count INTEGER, -- how many stages the walk had when last saved
ticks_json TEXT NOT NULL DEFAULT '[]', -- take: the step ids ticked done ("<sheet id>:<row>"); teach: the slide numbers practised
notes_json TEXT NOT NULL DEFAULT '{}', -- teach: { slide number: the trainer's note to self }
status TEXT NOT NULL DEFAULT 'started', -- started | done
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE UNIQUE INDEX walks_owner ON walks (email, session, kind);
Running It Locally
git clone, thennpm run build(the prompt linter, the schema check, the build, the output check) andnpm run serveon port 8787. The local server stands in for sign-in and the database with one preview admin and a few made-up rows, so every page can be looked at.- The output check refuses a Cloudflare preview address, the kits' all-capitals marker for an unsettled detail, filler Latin text, and a clock time on a learner page; the banned list is
bannedInOutputinhub.config.json. - The repo is
github.com/Learning-Lions-Arizona/learninglions-hub; the README has the Cloudflare setup, the variables and the house rules.