MyCroquet Module Package Standard
Version 2.0, 26 October 2026.
This document is for anyone who wants to build something that plugs into MyCroquet. You do not need access to our code to build a module. Read this, build to it, and hand us one folder. We plug it in.
You can run one kind of test on your own machine: the logic tests for your own src/lib file (section 4.5 shows how). Your route tests and your staging test need the real app. You write them, we run them at intake, and we send you the results.
If you use an AI to build it, give the AI this whole document. The prompt to paste is in section 8.
What changed in 2.0
- Club jobs come from committee positions. A club's Captain, Secretary and other office bearers get a card from their seat on the club's committee. No new roles. Section 1 and section 3.
- Every list of "who may do what" lives in ONE place, your own
src/lib/<id>.ts. The page guard and the routes import it. A guard that types its own list fails our tests. Section 3 and section 4.contract.requiresnames every tag your module uses. That is what lets a card be gifted. It does not keep anyone out: your routes do. Section 3.- New access helpers:
hasMemberAccessTo(members can remove cards),requirePositionOrCustomAccess(a seat or a gift) andexplicitOnly(keeps the association admin out). Section 4.3.- The look:
Herowith a real photo,apiFetchfor every call, the kit for every control, and the shared button and colour settings. Section 4.4.- One member card. No module builds its own view or editor of a member. Section 5.
- AI calls go only through the app's shared AI helper, and never carry personal details. Section 5.
- Your package may now hold
demo-data.json(made-up data for our demo site) and route tests insrc/app/(frontend)/api/<id>/__tests__/. Section 6.- Which tests you run on your own machine, and which you write for us to run at intake. Section 4.5.
- Every card is either club-level or association-wide, and your package must say which. Section 3.
- What happens after you send it: it waits switched off, we test it on our staging site, and the association signs off before it reaches live. Section 2.
- The taken ids, tags and icons are brought up to date. The worked example is rebuilt to all of the above and tested against the real app. Collection names must not clash with ours. Section 3 and section 4.7.
Start here
Everything below is also a repository, with the worked example as real files you can open, a blank template to copy, and the place to send your finished module.
automatebrisbaneai/mycroquet-module-kit →
Building it with an AI? Give it the plain-text copy of this page: mycroquet-module-standard.md
Want the shorter version first? Build for MyCroquet walks through the same ground in about four minutes.
1. What MyCroquet is, in one screen
MyCroquet is the member app for Croquet Queensland. Members log in at my.croquetqld.org. Clubs and the association run their business inside it.
The stack:
| Part | What we use |
|---|---|
| Framework | Next.js 16, App Router, React 19, TypeScript |
| Styling | Tailwind CSS 4, a shared shadcn-style component kit |
| Database | PocketBase (the pocketbase JS client, version 0.26) |
| Login | One login type. A member types their email and gets a code. No passwords. |
| Tests | Vitest for unit tests. Playwright for browser tests. |
People and access. Every person is one record in the users collection. Each has a role and an optional list of capabilities. Every gate in the app checks capability tags, not roles. A person gets a tag in one of three ways:
- Their role. Roles:
player,club_captain,club_membership,curator,caq_admin. A role is a small bundle of tags. Every role givesmember. - Their seat on their club's committee. Each club keeps its committee in MyCroquet. The positions are
president,secretary,treasurer,vice_president,captain,committee_memberandregistrar. A club may also list aprovidore, the person who looks after catering. That seat gives nothing.
A position can give a tag. It counts only at the person's own club. It counts only once the club has confirmed the seat.
The server reads the committee fresh on every write. So a change on the club's committee page takes effect at once. 3. A gift. A tag can be given straight to a named person. Each gift is for one person, and there is no limit on how many people get one. Gifts are for people who do the job without holding the seat.
admin is a wildcard tag. It passes every check unless the check says explicitOnly (section 3). It belongs to caq_admin only.
Cards. Members see each module as a card. Members can hide or remove most cards they do not want. A removed card is gone for that person everywhere: menu, page and API. Your routes must honour it. Section 4.3 shows how. ("Card" means four different things in this document. The glossary in section 9 lists them.)
What a module is. A module is one feature unit. It is one manifest file plus the pages and API routes it owns. From the manifest, the platform works out the menu entry, the page guard and the API guard. There is no other registration step.
Nearly forty modules exist in the code. Most of them are dark. Dark means enabled: false. A dark module is invisible and unreachable on the live site. The live site runs a small base of modules. New cards are added slowly, as clubs ask for them. So a finished module may wait, switched off, for a while.
2. How it plugs in
Your module lives inside MyCroquet. Your pages and API routes use our login, our database clients and our component kit. You write the code to this standard and hand it to us as one folder.
We do not embed outside products in a frame, and we do not open our login to outside sites. If your idea only works that way, talk to us first before building anything.
What happens after you send it
- It arrives switched off. Your manifest says
enabled: false. It stays that way. - We copy it in and test it. We add the few shared lines your
MODULE.mdasks for, run our whole test suite, and try it on our staging site. Staging is a copy of MyCroquet with made-up data. Today its address isstaging.my.croquetwade.com. That address may change. If it does, we update yourallowedHostson intake. You do not need to send a new version. - The association signs off. Adding a module always needs the association's sign-off before it reaches the live site. Until then it is not there.
- It reaches live still switched off. Nobody sees it yet.
- Switching it on is a separate step. How a module is switched on is being changed. Today it is a one-line change we make and release. It is becoming a setting we can turn on without a new release. Whichever way it is done, it is our step, not yours.
There is no fixed time for any of this. A club that wants your card asks the association, and the association decides when to switch it on.
3. The manifest: the contract you sign
One file. Two halves. contract says what you own and which tags your module uses. nav says how you appear in the menu.
These are the real types. Your manifest must satisfy them.
// The vocabulary of access. Your module adds new tags to this list
// (we add them on intake). "admin" passes every check.
export type CapabilityTag =
| "admin"
| "member" // any signed-in member
| "membership" // club membership officers, own club only
| "membership_caq" // association-level membership admin
| "captain" // club captains (also given by the captain and secretary seats)
| "curate" // editorial curators
| "club_committee" // any confirmed seat on your own club's committee. Seat only, never a gift.
| "club_officer" // president, secretary, treasurer, vice_president, captain. Seat only.
// ... about thirty more, one or two per module
| "your_new_tag"; // <- yours
// OR-logic: user holds ANY listed tag. AND-logic: user holds EVERY listed tag.
// explicitOnly: true switches the "admin" wildcard off for this one requirement.
export type Requirement =
| { anyCapability: readonly CapabilityTag[]; explicitOnly?: boolean }
| { allCapability: readonly CapabilityTag[]; explicitOnly?: boolean };
export interface ModuleContract {
id: string; // kebab-case, unique, matches your folder names
enabled: boolean; // ALWAYS false when you hand it in
requires: Requirement; // EVERY tag the module uses. See rule 9 below.
routes: readonly string[]; // page prefixes you own, e.g. ["/club-notices"]
apis: readonly string[]; // API prefixes you own, e.g. ["/api/club-notices"]
exemptApis?: readonly string[]; // never-gated endpoints (cron, webhook, public).
// EACH ONE needs a comment saying who calls it.
allowedHosts?: readonly string[]; // hosts where the module counts as enabled while dark
collections?: readonly string[]; // PocketBase collections you own. Documentation only.
}
export interface NavItem {
id: string;
label: string;
description: string; // one sentence a member understands
href: string;
icon: string; // an icon NAME from the list under this block
section: "Player" | "Membership" | "Come & Try" | "Come & Try Control"
| "Club Captain" | "CAQ" | "Gifting" | "Community"
| "Accreditations" | "Photos" | "Task Board";
order: number;
requires: Requirement; // who sees the MENU LINK. Must match your page guard.
}
export interface ModuleManifest {
contract: ModuleContract;
nav: readonly NavItem[];
}
Icons you may use. The nav resolves icon names through a fixed map. A name not in this list silently falls back to the Home icon. Pick one of:
Home, User, UserPlus, Users, Target, ClipboardList, ClipboardCheck, Trophy,
CheckCircle, Search, BarChart3, Award, Activity, Shield, Calendar, CalendarCog,
Building2, Globe, LogOut, FileText, Handshake, MessageSquare, HandCoins, Newspaper,
Video, Vote, Landmark, Camera, Radar, Gift, Image, History, Store, AlertTriangle, Mail
If none fits, name the lucide icon you want in MODULE.md. Adding it is a one-line change we make on intake.
Rules for the manifest file.
- It lives at
src/modules/<id>.tsand exports oneconstof typeModuleManifest. - It imports nothing except
./types. No React. No Next. No icons. No helpers. Not your own@/libfile either. The file is bundled into edge middleware. The build fails if you break this. enabledisfalse. Always. We switch it on, not you.allowedHostsmay contain our staging address, today"staging.my.croquetwade.com". On that address the module counts as switched on, even thoughenabledisfalse. Everywhere else, live included, it stays dark. That lets us walk through your module on staging. If the address changes, we change this line on intake.- Sections and the front page.
- Use an existing
section. A new section is a visible design change and needs our agreement first. - The front page shows one tile per section, not one per module. Your module shares its section's tile. Today these sections have a tile: Membership, Come & Try, Come & Try Control, Accreditations, Photos and Task Board. Player, Club Captain, CAQ, Gifting and Community have none, so a module there shows only in the menu. - A tile of its own means a section of its own. That is our call. - Ask for your own capability tag for each job that is not "any member". Name it in
MODULE.md. Do not reuse someone else's tag unless you write down why it is right. Reading something every member may read needs no new tag: usemember. - Your new tag does not exist in our code until we add it. So your manifest will fail a type check on the tag alone until intake. That is expected. Do not work around it by using an existing tag.
orderis where you sit inside your section. Pick any number from 40 to 99. We renumber on intake if it clashes.sectionis the audience who reads the entry. If readers and writers differ, pick the readers.contract.requireslists every tag your module uses: the reading tag and every acting tag. This is what lets the gifting screen offer your tag, and what lets a member remove your card. It does not keep anyone out. The module gate checks only whether the module is switched on:enabled, or anallowedHostsaddress (rule 4). Your routes are the real gate (section 4.3).nav[].requiresdecides only whether the menu link shows. It must ask for exactly what your page guard asks for. A link that leads to "Access denied", or a page nobody can find, both fail our tests.- Your lists of tags live in one place: your own
src/lib/<id>.ts. The page and the routes import them. They never type a tag string. The manifest cannot import that file (rule 2), so it types the same lists again, and your unit test checks that the two copies agree. Section 4.2 and 4.5 show both. explicitOnly. Leave it off unless your module holds private papers that even the association's admin should not open without being given the card. If you turn it on, turn it on everywhere for that tag: the requirement, the page guard (explicitOnlyprop onCapabilityGuard) and the routes (hasExplicitCapabilityfrom@/modules/capabilities). Say why inMODULE.md.- Club-level or association-wide. Every card is one or the other, and
MODULE.mdmust say which.- A club-level card works only inside the holder's own club. Letting a club's president or secretary gift it to people at their own club is being added. Until then, we give it.
- An association-wide card reaches every club. Only the association gives it.
Jobs that belong to a committee seat. If the people who do the job are a club's office bearers, do not ask for a role and do not ask clubs to gift the card to them one by one. Ask for a position grant: your tag, and the positions that get it automatically. For example, club_notices: ["captain", "secretary"]. We add that row on intake, because the table of position grants is shared by every module. (In our code it is called POSITION_GRANTS. You will see the name in the example's comments.) If someone without the seat may also do the job, say the tag can also be gifted. Most club jobs work this way: the seat gives the tag, and a gift covers anyone else.
Names already taken (as of 26 October 2026). Check yours is not on any list.
Module ids: accreditations-coaching, accreditations-refereeing, approvals, calendar, clip-review, club-mailer, club-profile, coaching-clips, come-and-try, committee, core, croquetvideos-search, ct-control, decisions, dev-personas, filing-cabinet, gifting, grants, handicap, healthy-mind, learn, meeting-minutes, membership, news-editor, newsletter, newsroom, peg, pennants, photos, policy-chat, publisher, rankings, submit-news, task-board, tournaments, website-editing. Also books and treasurer, which are kept for work in progress.
Capability tags: admin, member, membership, membership_caq, approvals, captain, curate, come_and_try, committee, newsletter, meeting_minutes, treasurer, grants, publisher, coaching_clips, peg, clip_reviewer, club_mailer, club_profile, photos, accreditations_coaching_admin, accreditations_refereeing_admin, accreditations_coaching_assessor, accreditations_refereeing_assessor, ct_control, gifting, website_editing, calendar_custom_access, calendar_review, club_committee, club_officer, ai_clearer, filing_cabinet_custom_access, agm_secretary, news_editor, news_club_editor.
Menu item ids must be new too. Most modules use their module id, so the list above covers them.
club-notices and club_notices belong to the worked example in section 4. Pick your own.
Collection names. Your collection names must not clash with a table we already have. We have many, and not all are listed here. Start every name with your module's own word, for example club_notice_posts, not notices. We check every name on intake. The worked example uses club_notice_posts because club_notices was already taken.
4. Worked example: a tiny native module
The module is club-notices. A club's Captain or Secretary posts a short notice. Every member of that club reads it. Nothing else.
It shows the three things most modules need: a card every member reads, a job given by a committee seat, and a gift for someone without the seat.
Every file is complete, and every file here is the same as the file in the kit's example/ folder. It was registered in a copy of the real app, and the app's whole test suite passed with it in place. Copy the shapes.
Who can do what:
| Person | Read | Post |
|---|---|---|
| Any member of the club | Yes, their own club only | No |
| The club's current Captain or Secretary | Yes | Yes, to their own club only |
Someone the club gifted club_notices to |
Yes | Yes, to their own club only |
| A committee member in any other seat (treasurer, president...) | Yes | No |
| A Captain of another club | Only their own club | Only their own club |
| A member who removed the Club Notices card | No | No |
| A Captain who removed just the posting card | Yes | No |
| The association's admin | The club on their own login only | The club on their own login only |
4.1 src/modules/club-notices.ts
The manifest. It types the access lists again, because it cannot import them (section 3, rule 11).
/**
* Club Notices: any member of a club reads that club's notices; the club's
* Captain and Secretary post them.
*
* This file may import only ./types (it runs in edge middleware), so it
* cannot import the access lists from src/lib/club-notices.ts. It types them
* again here, and src/lib/club-notices.test.ts checks the two copies agree.
*
* - contract.requires: every card this module uses. `member` reads;
* `club_notices` posts. Listing `club_notices` here is what lets the card
* be gifted. It does NOT keep anyone out: the routes do that.
* - nav requires: who sees the menu link. Matches the page guard exactly.
* - No explicitOnly: a CAQ admin may read and post, but only for the club on
* their own login (see MODULE.md, "CAQ admins").
*/
import type { ModuleManifest } from "./types";
export const clubNotices: ModuleManifest = {
contract: {
id: "club-notices",
enabled: false,
requires: { anyCapability: ["member", "club_notices"] },
routes: ["/club-notices"],
apis: ["/api/club-notices"],
allowedHosts: ["staging.my.croquetwade.com"],
collections: ["club_notice_posts"],
},
nav: [
{
id: "club-notices",
label: "Club Notices",
description: "Your club's notices, posted by your Captain and Secretary.",
href: "/club-notices",
icon: "MessageSquare",
section: "Community",
order: 60,
requires: { anyCapability: ["member"] },
},
],
};
4.2 src/lib/club-notices.ts
Pure types and logic, plus the ONE place that says who may do what. No Next imports here, so it is easy to unit test.
/**
* Club Notices: the one place that says who may do what, plus the pure logic.
* No Next imports here, so it is easy to unit test, and the page, the API
* route and the tests all import their access lists from this file. Nothing
* types a list of tags anywhere else, apart from the manifest (which cannot
* import from here) and a test checks that copy agrees.
*/
import type { CapabilityTag } from "@/modules/types";
/** The module id. The manifest types it again; a test checks they agree. */
export const MODULE_ID = "club-notices";
/** The database collection this module owns. */
export const NOTICES_COLLECTION = "club_notice_posts";
/**
* The card that lets a person POST a notice for their own club.
*
* Two ways in, and they stack:
* - the club's Captain and Secretary hold it automatically, from their
* committee position in Club Profile (the POSITION_GRANTS row we add on
* intake, asked for in MODULE.md). It is re-read live on every post.
* - anyone else the club wants can be gifted the card.
*/
export const CLUB_NOTICES_TAG = "club_notices" satisfies CapabilityTag;
/** Who may OPEN the page and READ their own club's notices: every signed-in member. */
export const CLUB_NOTICES_READ: CapabilityTag[] = ["member"];
/** Who may POST: the club_notices card (position or gift, see above). */
export const CLUB_NOTICES_POST: CapabilityTag[] = [CLUB_NOTICES_TAG];
/** The header photo. A real photo already in the app, never a generated one. */
export const CLUB_NOTICES_PHOTO = "/img/home/cards/club-profile-clubhouse.jpg";
export interface NoticeRecord {
id: string;
club: string; // relation to clubs
author: string; // relation to users
body: string;
created: string;
updated: string;
}
export const MAX_BODY = 500;
/** Returns an error message, or null when the body is acceptable. */
export function validateBody(body: unknown): string | null {
if (typeof body !== "string") return "Write the notice first.";
const trimmed = body.trim();
if (!trimmed) return "Write the notice first.";
if (trimmed.length > MAX_BODY) return `Keep it under ${MAX_BODY} characters.`;
return null;
}
/** "26 October 2026": dates for people, in Queensland time. */
export function noticeDate(created: string): string {
const d = new Date(created.replace(" ", "T"));
if (Number.isNaN(d.getTime())) return "";
return d.toLocaleDateString("en-AU", {
day: "numeric",
month: "long",
year: "numeric",
timeZone: "Australia/Brisbane",
});
}
4.3 src/app/(frontend)/api/club-notices/route.ts
This is the most important file. Every authed API handler opens with the same three gates, in this order:
- module gate
- authenticate
- authorise
import { NextRequest, NextResponse } from "next/server";
import { requireModuleApi } from "@/lib/api-module-gate";
import { verifyAuth, hasMemberAccessTo, AuthError, type VerifiedUser } from "@/lib/api-auth";
import { getAdminPB } from "@/lib/pocketbase-admin";
import { requirePositionOrCustomAccess } from "@/lib/positions";
import {
CLUB_NOTICES_POST,
CLUB_NOTICES_TAG,
MODULE_ID,
NOTICES_COLLECTION,
validateBody,
type NoticeRecord,
} from "@/lib/club-notices";
const POST_REFUSED =
"Only your club's Captain or Secretary can post a notice. If you should be able to, ask your club to put you on its committee in Club Profile.";
/** 2. authenticate. Answers the signed-in user, or a 401 to send back. */
async function signedIn(req: NextRequest): Promise<VerifiedUser | NextResponse> {
try {
return await verifyAuth(req.headers.get("Authorization"));
} catch (e) {
const status = e instanceof AuthError ? e.status : 401;
return NextResponse.json({ error: "Please sign in again." }, { status });
}
}
// GET /api/club-notices -> the caller's OWN club's notices, newest first
export async function GET(req: NextRequest) {
const gate = requireModuleApi(MODULE_ID, req.headers.get("host"));
if (gate) return gate; // 1. module gate
const user = await signedIn(req); // 2. authenticate
if (user instanceof NextResponse) return user;
if (!hasMemberAccessTo(user, MODULE_ID)) {
// 3. authorise (a removed card is refused too)
return NextResponse.json({ error: "You do not have Club Notices." }, { status: 403 });
}
if (!user.clubId) return NextResponse.json({ notices: [] }); // no club on the login, nothing to show
try {
const pb = await getAdminPB();
const page = await pb.collection(NOTICES_COLLECTION).getList<NoticeRecord>(1, 50, {
// The club comes from the signed-in user, never from the request.
filter: pb.filter("club = {:club}", { club: user.clubId }),
sort: "-created",
});
return NextResponse.json({ notices: page.items });
} catch (e) {
console.error("[club-notices] list failed:", e);
return NextResponse.json({ error: "Could not load the notices." }, { status: 500 });
}
}
// POST /api/club-notices { body: string } -> a notice for the caller's OWN club
export async function POST(req: NextRequest) {
const gate = requireModuleApi(MODULE_ID, req.headers.get("host"));
if (gate) return gate; // 1. module gate
const user = await signedIn(req); // 2. authenticate
if (user instanceof NextResponse) return user;
// 3. authorise
if (!hasMemberAccessTo(user, MODULE_ID)) {
return NextResponse.json({ error: "You do not have Club Notices." }, { status: 403 });
}
try {
// A write never trusts the cached positions on the login. This re-reads
// the club's committee live: Captain or Secretary at the user's OWN club,
// or a gifted club_notices card. A removed card is refused.
await requirePositionOrCustomAccess(user, CLUB_NOTICES_TAG, CLUB_NOTICES_POST, POST_REFUSED);
} catch (e) {
if (e instanceof AuthError) return NextResponse.json({ error: e.message, code: e.code }, { status: e.status });
throw e;
}
if (!user.clubId) {
return NextResponse.json({ error: "Your login has no club, so there is nowhere to post." }, { status: 403 });
}
let payload: { body?: unknown };
try {
payload = await req.json();
} catch {
return NextResponse.json({ error: "Write the notice first." }, { status: 400 });
}
const problem = validateBody(payload.body);
if (problem) return NextResponse.json({ error: problem }, { status: 400 });
try {
const pb = await getAdminPB();
const created = await pb.collection(NOTICES_COLLECTION).create<NoticeRecord>({
club: user.clubId, // the server decides the club, never the request
author: user.userId,
body: (payload.body as string).trim(),
});
return NextResponse.json({ notice: created }, { status: 201 });
} catch (e) {
console.error("[club-notices] create failed:", e);
return NextResponse.json({ error: "Could not save the notice. Nothing was posted." }, { status: 500 });
}
}
Which check for step 3. Pick by what the route does. Every name comes from your own src/lib/<id>.ts, never a typed string.
| The route... | Step 3 is | From |
|---|---|---|
is for every member (a card that needs only member) |
hasMemberAccessTo(user, MODULE_ID) |
@/lib/api-auth |
| writes, and every member may do it (for example, a member books a lawn for themselves) | hasMemberAccessTo(user, MODULE_ID), then take the club and the member from the login |
@/lib/api-auth |
| reads, for holders of your own tag | hasCapabilityServer(user, YOUR_TAG) |
@/lib/api-auth |
| writes, and a committee seat or a gift may do it | await requirePositionOrCustomAccess(user, YOUR_TAG, [YOUR_TAG], message) |
@/lib/positions |
| writes, and only a seat may do it (never a gift) | await requirePositionCapability(user, YOUR_TAG, message) |
@/lib/positions |
| writes, and only a gift may do it (no seat gives it) | hasCapabilityServer(user, YOUR_TAG) |
@/lib/api-auth |
has explicitOnly on |
hasExplicitCapability(user.capabilities, YOUR_TAG) |
@/modules/capabilities |
Things to notice:
verifyAuthgives youuser.userId,user.memberId,user.clubId,user.role,user.capabilitiesanduser.positions.- A card every member holds needs
hasMemberAccessTo, nothasCapabilityServer(user, "member"). OnlyhasMemberAccessTorefuses a member who removed your card. A tagged card's removal is honoured by every check in the table. - How a removed card is stored. When a member removes a card, the app writes a marker into that member's own list of tags.
no:module:<module id>removes a card that has no tag of its own, such as one every member holds.no:<tag>removes a tagged card. A marker may end with@and the tags whose gift brings the card back, for exampleno:club_notices@club_notices. You never write or read these markers yourself: the checks in the table do. Use these same shapes in your route tests. - Never trust
user.positionsfor a write. It is a cached copy.requirePositionOrCustomAccessandrequirePositionCapabilityread the club's committee fresh. They throw anAuthErrorwithcode: "position_required"when the answer is no. Catch it and send back its message and status, as the example does. requirePositionCapabilityalone refuses a gifted holder. Use it only when your tag can never be gifted. If the job can also be gifted, userequirePositionOrCustomAccess.- A seat counts only at the person's own club. A Captain of club B gets nothing at club A.
- The club comes from the verified user, never from the request body or the address. That is club scoping. Members of one club must never see another club's data.
pb.filter()with named placeholders is the only way to build a filter.getAdminPB()is the only way to reach the database from a route. Do not create your own PocketBase client. It returns the full PocketBase JS SDK client, sogetList,getFullList,getFirstListItem,getOne,create,updateanddeleteall work.- Error messages are plain sentences a member can act on.
Update and delete. Put them in a dynamic route file src/app/(frontend)/api/club-notices/[id]/route.ts exporting PATCH and DELETE. Same three gates in the same order. Then load the record with getOne, and refuse with 404 if its club is not user.clubId. Only then write. The club check on the record is what stops a captain editing another club's notice.
Time and dates. Queensland has no daylight saving. When "today" matters, decide it on the server in Australia/Brisbane. Format dates for people with toLocaleDateString("en-AU", { ..., timeZone: "Australia/Brisbane" }), as noticeDate does.
4.4 src/app/(frontend)/club-notices/page.tsx
A gated client page using the shared component kit.
"use client";
// /club-notices: one job, read your club's notices. The Captain and Secretary
// also see a box to post one. Who gets in comes from ONE list,
// CLUB_NOTICES_READ, imported from the module's own lib file; the server
// routes are the real gate.
import { useCallback, useEffect, useState } from "react";
import AuthGuard from "@/components/AuthGuard";
import CapabilityGuard from "@/components/CapabilityGuard";
import { Hero } from "@/components/hub/Hero";
import { Button, Notice, RecordCard, Spinner, Textarea } from "@/components/ui";
import pb from "@/lib/pocketbase";
import { apiFetch } from "@/lib/api-fetch";
import { useAuth, hasCapability } from "@/lib/auth";
import { effectiveCapabilities } from "@/modules/capabilities";
import {
CLUB_NOTICES_PHOTO,
CLUB_NOTICES_POST,
CLUB_NOTICES_READ,
MAX_BODY,
noticeDate,
type NoticeRecord,
} from "@/lib/club-notices";
function authHeaders(): HeadersInit {
return { Authorization: `Bearer ${pb.authStore.token}` };
}
function NoticesInner() {
const { role, capabilities, positions } = useAuth();
// SHOW path only: decides whether the box appears. The server re-checks
// the committee live on every post.
const canPost = hasCapability(effectiveCapabilities(role, capabilities, positions), CLUB_NOTICES_POST);
const [notices, setNotices] = useState<NoticeRecord[] | null>(null);
const [loadError, setLoadError] = useState("");
const [draft, setDraft] = useState("");
const [postError, setPostError] = useState("");
const [saved, setSaved] = useState(false);
const [saving, setSaving] = useState(false);
const load = useCallback(async () => {
try {
const data = await apiFetch<{ notices: NoticeRecord[] }>("/api/club-notices", { headers: authHeaders() });
setNotices(data.notices ?? []);
setLoadError("");
} catch (e) {
setLoadError(e instanceof Error ? e.message : "Could not load the notices.");
setNotices([]);
}
}, []);
useEffect(() => {
load();
}, [load]);
async function post() {
setSaving(true);
setPostError("");
setSaved(false);
try {
await apiFetch("/api/club-notices", {
method: "POST",
headers: { ...authHeaders(), "Content-Type": "application/json" },
body: JSON.stringify({ body: draft }),
});
setDraft("");
setSaved(true);
await load();
} catch (e) {
setPostError(e instanceof Error ? e.message : "Could not save the notice. Nothing was posted.");
} finally {
setSaving(false);
}
}
const count = notices?.length ?? 0;
return (
<div className="mx-auto max-w-3xl space-y-6 p-4 sm:p-8">
<Hero
eyebrow="Your club"
title="Club Notices"
subtitle="Short notices from your club's Captain and Secretary."
bgImage={CLUB_NOTICES_PHOTO}
wash="green"
/>
{canPost && (
<RecordCard title="Post a notice">
<div className="space-y-3 pt-3">
<Textarea
label="Your notice"
value={draft}
onChange={(e) => {
setDraft(e.target.value);
setSaved(false);
}}
maxLength={MAX_BODY}
placeholder="For example: Lawn 2 is closed on Tuesday for repairs."
/>
<p className="text-sm text-muted-foreground">
Every member of your club will see it. {MAX_BODY - draft.length} characters left.
</p>
{postError && <Notice variant="error">{postError}</Notice>}
{saved && <Notice variant="saved">Posted. Your club can see it now.</Notice>}
<Button size="lg" onClick={post} loading={saving} disabled={!draft.trim()}>
Post this notice
</Button>
</div>
</RecordCard>
)}
{loadError && <Notice variant="error">{loadError}</Notice>}
{notices === null && (
<div className="flex justify-center py-12">
<Spinner />
</div>
)}
{notices !== null && !loadError && (
<p className="text-base text-muted-foreground" data-testid="notices-count">
{count === 0
? "Your club has no notices yet."
: count === 1
? "One notice from your club."
: `${count} notices from your club, newest first.`}
</p>
)}
{notices?.map((n) => (
<RecordCard key={n.id} title={noticeDate(n.created)}>
<p className="whitespace-pre-wrap pt-2 text-base">{n.body}</p>
</RecordCard>
))}
</div>
);
}
export default function ClubNoticesPage() {
return (
<AuthGuard>
<CapabilityGuard capabilities={CLUB_NOTICES_READ}>
<NoticesInner />
</CapabilityGuard>
</AuthGuard>
);
}
The page guard names a list; it never types one. <CapabilityGuard capabilities={CLUB_NOTICES_READ}>, imported from your own lib file. Our app-wide test fails any page that types its own list of tags.
The page only decides what to show. Hiding the post box is a courtesy. The server route is the real gate. To decide what to show, read the person's tags through effectiveCapabilities(role, capabilities, positions), as the example does, so seats and removed cards count.
The look. MyCroquet has one look. Do not invent a new one.
Heroat the top of every page, from@/components/hub/Hero. Never a bare<h1>. Props:eyebrow,title,subtitle,bgImage,wash("maroon"or"green"), and an optionalstatwhen there is one real number worth showing.- A real photo.
bgImageis a real photo the app already holds, never a drawing or a made-up picture. The example uses/img/home/cards/club-profile-clubhouse.jpg. If none fits, say what you want under "Open questions" inMODULE.mdand we will find a real one. apiFetchfor every call, from@/lib/api-fetch. Never a barefetch. It times out, and it turns every failure into one plain message a member can act on. On a 4xx it throws an error carrying the server's own message, plusstatusandcode.- The kit for every control. Import from
@/components/ui:Button, LinkButton, Card, Input, SearchInput, Select, Textarea, Checkbox, Switch, ListPicker, ToggleGroup, Tabs, Table, ScheduleRow, Spinner, Notice, StatusChip, ActionCard, CheckNote, NavTile, DemoBadge, HonourBoard, SectionHead, ToastProvider, useToast, Skeleton, SkeletonCard, SkeletonTableRows, SkeletonMemberCard, RecordCard, RecordRow, RecordEditRow, RecordFieldRow, RecordSheet, RecordGroup. Guards are default exports one level up:@/components/AuthGuard,@/components/CapabilityGuard,@/components/RoleGuard. Do not bring in another component library. - Buttons look the same everywhere. Use
ButtonorLinkButtonwith their ownvariantandsize. Never give a button your own corner (rounded-...) or colour. The corner comes from one shared setting, so a change to it changes every button at once. A test fails a build that writes its own button corner. - Groups of details sit on a soft colour. When a page lists details in groups, use
RecordSheetwithRecordGroup, and give each group atintfrom 1 to 5. Never pick your own background colour. - Colours and sizes come from the kit's names, such as
text-muted-foreground,border-borderandbg-surface. No hex colours. No one-off text sizes. - No training needed. A member using the screen for the first time must manage without a help page or a tour. Label everything in plain words.
- Member pages are
max-w-3xl. Touch targets are at least 44 pixels tall.
Prop contracts you will need most:
Input,Textarea,Selectall require alabelstring. They accept every normal HTML attribute for their element (value,onChange,placeholder,maxLength,disabled) plus an optionalerrorstring.Selectalso takesoptions: { value: string; label: string }[]and an optionalplaceholder. It renders a native select, soonChangegives youe.target.value.Buttontakesvariant(primary,secondary,ghost,danger,outline,greenOutline,maroonOutline,quiet),size(sm,default,lg,icon),loadingandfullWidth.RecordCardtakes atitleand children.Noticetakes children and avariant:error(the default),saved,advisoryorplain.
4.5 The unit tests
Two unit test files, plus the browser test in section 4.6.
Which tests you can run yourself.
| Test | Where it runs |
|---|---|
src/lib/<id>.test.ts, the logic and the lists |
On your own machine. It needs nothing from our app. |
src/app/(frontend)/api/<id>/__tests__/route.test.ts, the route |
Only inside our app. You write it; we run it at intake and send you the results. |
tests/<id>.staging.spec.ts, the browser test |
Only against our staging site. You write it; we run it at intake and send you the results. |
To run the logic test on your own machine, keep two small files in a working folder, beside your package and outside it:
my-work/
package.json { "private": true, "type": "module", "devDependencies": { "vitest": "^4.1.0" } }
vitest.config.ts (below)
mycroquet-module-<id>/ your package
import { defineConfig } from "vitest/config";
import path from "node:path";
// Run from inside your package folder. "@" points at the package's own src.
export default defineConfig({
resolve: { alias: { "@": path.resolve(process.cwd(), "src") } },
});
Run npm install once in my-work. Then, from inside your package folder: npx vitest run --config ../vitest.config.ts src/lib/<id>.test.ts. The worked example's logic test passes this way, 12 of 12. This works only while your lib file and your manifest import nothing but types from the app (import type). Do not put package.json, vitest.config.ts or node_modules inside the package you hand in.
src/lib/club-notices.test.ts checks the logic, checks that the manifest's copy of the lists agrees with the lib's, and checks that the page and the route name the shared lists and never type a tag:
import { describe, it, expect } from "vitest";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { clubNotices } from "@/modules/club-notices";
import {
CLUB_NOTICES_POST,
CLUB_NOTICES_READ,
CLUB_NOTICES_TAG,
MAX_BODY,
MODULE_ID,
NOTICES_COLLECTION,
noticeDate,
validateBody,
} from "./club-notices";
const APP = join(process.cwd(), "src", "app", "(frontend)");
const PAGE = join(APP, "club-notices", "page.tsx");
const ROUTE = join(APP, "api", "club-notices", "route.ts");
/** The source with comments taken out, so a list typed in a comment does not count. */
function code(file: string): string {
return readFileSync(file, "utf-8").replace(/\/\*[\s\S]*?\*\//g, "").replace(/(^|[^:])\/\/.*$/gm, "$1");
}
function anyOf(req: unknown): string[] {
expect(req, "not an any-of requirement").toHaveProperty("anyCapability");
return [...(req as { anyCapability: readonly string[] }).anyCapability].sort();
}
describe("validateBody", () => {
it("rejects empty", () => {
expect(validateBody("")).toBeTruthy();
expect(validateBody(" ")).toBeTruthy();
expect(validateBody(undefined)).toBeTruthy();
});
it("rejects too long", () => {
expect(validateBody("x".repeat(MAX_BODY + 1))).toBeTruthy();
});
it("accepts a normal notice", () => {
expect(validateBody("Lawn 2 closed Tuesday for repairs.")).toBeNull();
});
});
describe("noticeDate", () => {
it("writes the day in Queensland time, in words", () => {
// 14:30 UTC on the 25th is 00:30 on the 26th in Brisbane.
expect(noticeDate("2026-10-25 14:30:00.000Z")).toBe("26 October 2026");
});
it("answers blank for a value that is not a date", () => {
expect(noticeDate("")).toBe("");
});
});
describe("the manifest's copy of the lists agrees with this module's own lists", () => {
// The manifest cannot import from @/lib (it runs in edge middleware), so it
// types the lists again. This is the check that the two copies still agree.
it("same id and collection", () => {
expect(clubNotices.contract.id).toBe(MODULE_ID);
expect(clubNotices.contract.collections).toEqual([NOTICES_COLLECTION]);
});
it("the menu link asks for exactly the read list", () => {
expect(clubNotices.nav).toHaveLength(1);
expect(anyOf(clubNotices.nav[0].requires)).toEqual([...CLUB_NOTICES_READ].sort());
});
it("contract.requires names every card the module uses: read and post", () => {
expect(anyOf(clubNotices.contract.requires)).toEqual([...new Set([...CLUB_NOTICES_READ, ...CLUB_NOTICES_POST])].sort());
expect(CLUB_NOTICES_POST).toEqual([CLUB_NOTICES_TAG]);
});
it("explicitOnly is off on both, as MODULE.md says", () => {
expect(clubNotices.contract.requires.explicitOnly).toBeFalsy();
expect(clubNotices.nav[0].requires.explicitOnly).toBeFalsy();
});
it("ships dark", () => {
expect(clubNotices.contract.enabled).toBe(false);
});
});
describe("the page and the route read the shared lists, never a typed list", () => {
it("the page guard names CLUB_NOTICES_READ and types no tag", () => {
const src = code(PAGE);
expect(src).toMatch(/<CapabilityGuard\s+capabilities=\{CLUB_NOTICES_READ\}>/);
expect(src).not.toMatch(/capabilities=\{\[/);
expect(src).not.toMatch(/["']club_notices["']/);
});
it("the route gates on the shared names and types no tag", () => {
const src = code(ROUTE);
expect(src).toContain("hasMemberAccessTo(user, MODULE_ID)");
expect(src).toContain("requirePositionOrCustomAccess(user, CLUB_NOTICES_TAG, CLUB_NOTICES_POST,");
expect(src).not.toMatch(/hasCapabilityServer\(/);
expect(src).not.toMatch(/["'](member|club_notices)["']/);
});
});
src/app/(frontend)/api/club-notices/__tests__/route.test.ts calls the real route. It fakes only the sign-in check and the database. The access decisions are the app's real ones. Copy this shape. Your route test must prove the refusals, not only the happy path:
- a member who removed the card is refused;
- a member with no seat cannot act;
- a seat in another position, or at another club, gives nothing;
- a seat the club has not confirmed gives nothing;
- a position cached on the login, with no seat behind it, gives nothing;
- a club named in the request is ignored;
- what the association's admin can and cannot reach;
- every refusal writes nothing.
The fake committee. The route test fakes our committee table, club_officeholders. Each row is { member, club, position, data_status }. data_status: "current" is a seat the club has confirmed; any other value gives nothing. That table is ours. Your routes never read it: the access helpers do. In your route test, copy the example's fake as it is, and use only those four fields.
/**
* /api/club-notices: who may read, who may post, and what each one gets.
*
* Only the sign-in check and the database are faked. The access decisions are
* the app's real ones: hasMemberAccessTo, requirePositionOrCustomAccess and
* the live committee read behind it. The committee is a fake
* club_officeholders table the real resolver reads, so a stale position cached
* on the login cannot pass, exactly as on the real site.
*
* The "may post" tests need the POSITION_GRANTS row MODULE.md asks for
* (club_notices: captain, secretary). Without it they fail, which is the point.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
const mockVerifyAuth = vi.fn();
vi.mock("@/lib/api-auth", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/api-auth")>();
return { ...actual, verifyAuth: (...args: unknown[]) => mockVerifyAuth(...args) };
});
vi.mock("@/lib/api-module-gate", () => ({ requireModuleApi: () => null }));
type Row = Record<string, unknown>;
const db: Record<string, Row[]> = {};
const created: Row[] = [];
/** pb.filter(): kept as data, so the fake can apply the bound values. */
function filter(expr: string, params: Record<string, unknown>): string {
return JSON.stringify({ expr, params });
}
function matches(row: Row, f: string | undefined): boolean {
if (!f) return true;
const { expr, params } = JSON.parse(f) as { expr: string; params: Record<string, unknown> };
// Each "field = {:name}" clause, joined by &&.
return expr.split("&&").every((clause) => {
const m = /^\s*(\w+)\s*=\s*\{:(\w+)\}\s*$/.exec(clause);
if (!m) throw new Error(`fake PB cannot read filter clause: ${clause}`);
return row[m[1]] === params[m[2]];
});
}
const fakePB = {
filter,
collection: (name: string) => ({
getFullList: async (opts: { filter?: string } = {}) => (db[name] ?? []).filter((r) => matches(r, opts.filter)),
getList: async (_page: number, _per: number, opts: { filter?: string } = {}) => ({
items: (db[name] ?? []).filter((r) => matches(r, opts.filter)),
}),
create: async (data: Row) => {
const row = { id: `n${created.length + 1}`, ...data };
created.push({ collection: name, ...row });
return row;
},
}),
};
vi.mock("@/lib/pocketbase-admin", () => ({
getAdminPB: async () => fakePB,
withAdminPB: async (fn: (pb: unknown) => unknown) => fn(fakePB),
}));
import { GET, POST } from "../route";
const CLUB_A = "club-a";
const CLUB_B = "club-b";
/** A plain member of club A. Each test changes only what it is about. */
const MEMBER = {
role: "player",
email: "member@club-a.test",
memberId: "m-plain",
userId: "u-plain",
clubId: CLUB_A,
capabilities: [] as string[],
positions: [] as string[],
};
function seat(member: string, club: string, position: string, data_status = "current") {
db.club_officeholders.push({ member, club, position, data_status });
}
function getReq() {
return new NextRequest("http://localhost/api/club-notices", { headers: { Authorization: "Bearer t" } });
}
function postReq(body: unknown) {
return new NextRequest("http://localhost/api/club-notices", {
method: "POST",
headers: { Authorization: "Bearer t", "Content-Type": "application/json" },
body: JSON.stringify(body),
});
}
beforeEach(() => {
mockVerifyAuth.mockReset();
mockVerifyAuth.mockResolvedValue(MEMBER);
created.length = 0;
db.club_officeholders = [];
db.club_notice_posts = [
{ id: "a1", club: CLUB_A, author: "u-cap", body: "Lawn 2 closed Tuesday." },
{ id: "b1", club: CLUB_B, author: "u-other", body: "Club B only: AGM moved." },
];
});
describe("GET: any member reads their OWN club's notices", () => {
it("a plain member gets club A's notices and never club B's", async () => {
const res = await GET(getReq());
expect(res.status).toBe(200);
const { notices } = await res.json();
expect(notices.map((n: Row) => n.id)).toEqual(["a1"]);
});
it("a club B member gets only club B's", async () => {
mockVerifyAuth.mockResolvedValue({ ...MEMBER, clubId: CLUB_B });
const { notices } = await (await GET(getReq())).json();
expect(notices.map((n: Row) => n.id)).toEqual(["b1"]);
});
it("a ?club= in the address is ignored: the club comes from the login", async () => {
const req = new NextRequest(`http://localhost/api/club-notices?club=${CLUB_B}`, {
headers: { Authorization: "Bearer t" },
});
const { notices } = await (await GET(req)).json();
expect(notices.map((n: Row) => n.id)).toEqual(["a1"]);
});
it("a login with no club gets an empty list, not every club's", async () => {
mockVerifyAuth.mockResolvedValue({ ...MEMBER, clubId: undefined });
expect(await (await GET(getReq())).json()).toEqual({ notices: [] });
});
it("a member who removed the Club Notices card is refused", async () => {
mockVerifyAuth.mockResolvedValue({ ...MEMBER, capabilities: ["no:module:club-notices"] });
expect((await GET(getReq())).status).toBe(403);
});
it("not signed in: 401", async () => {
mockVerifyAuth.mockRejectedValue(new Error("bad token"));
expect((await GET(getReq())).status).toBe(401);
});
});
describe("POST: the club's Captain and Secretary post, from their committee seat", () => {
it("a current Captain of club A may post, and the notice lands on club A", async () => {
seat("m-cap", CLUB_A, "captain");
mockVerifyAuth.mockResolvedValue({ ...MEMBER, memberId: "m-cap", userId: "u-cap" });
const res = await POST(postReq({ body: " Lawn 3 open again. " }));
expect(res.status).toBe(201);
expect(created).toEqual([
{ collection: "club_notice_posts", id: "n1", club: CLUB_A, author: "u-cap", body: "Lawn 3 open again." },
]);
});
it("a current Secretary of club A may post", async () => {
seat("m-sec", CLUB_A, "secretary");
mockVerifyAuth.mockResolvedValue({ ...MEMBER, memberId: "m-sec", userId: "u-sec" });
expect((await POST(postReq({ body: "Fees due 1 July." }))).status).toBe(201);
});
it("a club in the request body is ignored: the notice goes to the poster's own club", async () => {
seat("m-cap", CLUB_A, "captain");
mockVerifyAuth.mockResolvedValue({ ...MEMBER, memberId: "m-cap", userId: "u-cap" });
await POST(postReq({ body: "Hello", club: CLUB_B }));
expect(created[0].club).toBe(CLUB_A);
});
it("someone gifted the club_notices card may post without a seat", async () => {
mockVerifyAuth.mockResolvedValue({ ...MEMBER, capabilities: ["club_notices"] });
expect((await POST(postReq({ body: "Working bee Saturday." }))).status).toBe(201);
});
});
describe("POST: everyone else is refused, and nothing is written", () => {
it("a member with no position cannot post", async () => {
const res = await POST(postReq({ body: "Hi" }));
expect(res.status).toBe(403);
expect((await res.json()).code).toBe("position_required");
expect(created).toEqual([]);
});
it("another committee position (treasurer, committee member) does not give posting", async () => {
seat("m-tre", CLUB_A, "treasurer");
seat("m-tre", CLUB_A, "committee_member");
mockVerifyAuth.mockResolvedValue({ ...MEMBER, memberId: "m-tre" });
expect((await POST(postReq({ body: "Hi" }))).status).toBe(403);
expect(created).toEqual([]);
});
it("a position cached on the login, with no live seat behind it, cannot post", async () => {
mockVerifyAuth.mockResolvedValue({ ...MEMBER, positions: ["captain"] });
expect((await POST(postReq({ body: "Hi" }))).status).toBe(403);
expect(created).toEqual([]);
});
it("a seat that is not confirmed yet gives nothing", async () => {
seat("m-cap", CLUB_A, "captain", "unconfirmed");
mockVerifyAuth.mockResolvedValue({ ...MEMBER, memberId: "m-cap" });
expect((await POST(postReq({ body: "Hi" }))).status).toBe(403);
});
it("a Captain of ANOTHER club cannot post at their own login's club", async () => {
seat("m-cap", CLUB_B, "captain");
mockVerifyAuth.mockResolvedValue({ ...MEMBER, memberId: "m-cap", clubId: CLUB_A });
expect((await POST(postReq({ body: "Hi" }))).status).toBe(403);
expect(created).toEqual([]);
});
it("a Captain who removed the club_notices card cannot post (and can still read)", async () => {
seat("m-cap", CLUB_A, "captain");
const removed = { ...MEMBER, memberId: "m-cap", capabilities: ["no:club_notices@club_notices"] };
mockVerifyAuth.mockResolvedValue(removed);
expect((await POST(postReq({ body: "Hi" }))).status).toBe(403);
expect((await GET(getReq())).status).toBe(200);
expect(created).toEqual([]);
});
it("a Captain who removed the whole Club Notices card cannot post", async () => {
seat("m-cap", CLUB_A, "captain");
mockVerifyAuth.mockResolvedValue({ ...MEMBER, memberId: "m-cap", capabilities: ["no:module:club-notices"] });
expect((await POST(postReq({ body: "Hi" }))).status).toBe(403);
expect(created).toEqual([]);
});
it("an empty notice is refused", async () => {
seat("m-cap", CLUB_A, "captain");
mockVerifyAuth.mockResolvedValue({ ...MEMBER, memberId: "m-cap" });
expect((await POST(postReq({ body: " " }))).status).toBe(400);
expect(created).toEqual([]);
});
});
describe("CAQ admins (explicitOnly is off, see MODULE.md)", () => {
const ADMIN = { ...MEMBER, role: "caq_admin", memberId: "m-admin", userId: "u-admin" };
it("reads and posts for the club on their own login, with no seat", async () => {
mockVerifyAuth.mockResolvedValue(ADMIN);
const { notices } = await (await GET(getReq())).json();
expect(notices.map((n: Row) => n.id)).toEqual(["a1"]);
expect((await POST(postReq({ body: "From CAQ." }))).status).toBe(201);
expect(created[0].club).toBe(CLUB_A);
});
it("never reaches another club: with no club on the login, nothing to read and nowhere to post", async () => {
mockVerifyAuth.mockResolvedValue({ ...ADMIN, clubId: undefined });
expect(await (await GET(getReq())).json()).toEqual({ notices: [] });
expect((await POST(postReq({ body: "Hi", club: CLUB_B }))).status).toBe(403);
expect(created).toEqual([]);
});
});
4.6 tests/club-notices.staging.spec.ts
A browser test that runs against our staging site. You cannot run it. Write it anyway: we run it at intake and send you the results. Our helper file ./helpers exports login(page, email), ADMIN_EMAIL, PLAYER_EMAIL, CLUBADMIN_EMAIL, CLUBADMIN2_EMAIL, TEST_PREFIX and uniqueName().
ADMIN_EMAILis the association's admin.PLAYER_EMAILis a plain member.CLUBADMIN_EMAILandCLUBADMIN2_EMAILare club-side logins at two different clubs, for cross-club tests.
Staging data changes, and which committee seats these logins hold is not fixed. So do not write a browser test that depends on a seat. Your route tests prove the seat rules.
import { test, expect } from "@playwright/test";
import { login, ADMIN_EMAIL } from "./helpers";
// Runs against the staging site, where the module is lit by its allowedHosts
// entry while it is still dark on the live site. The access rules themselves
// (who may post, other clubs, removed cards) are proved by the unit tests in
// src/app/(frontend)/api/club-notices/__tests__/route.test.ts.
test.describe("club-notices", () => {
test("a signed-in member sees the notices page", async ({ page }) => {
await login(page, ADMIN_EMAIL);
await page.goto("/club-notices");
await expect(page.getByRole("heading", { level: 1, name: "Club Notices" })).toBeVisible();
await expect(page.getByTestId("notices-count")).toBeVisible();
});
test("reading without signing in is refused", async ({ request }) => {
const res = await request.get("/api/club-notices");
expect([401, 404]).toContain(res.status()); // 404 when dark, 401 when lit
});
test("posting without signing in is refused and writes nothing", async ({ request }) => {
const res = await request.post("/api/club-notices", { data: { body: "should not land" } });
expect([401, 404]).toContain(res.status());
});
});
4.7 collections.json
Describe every PocketBase collection you need. We create it. All five API rules are null. That means only the server can touch it. Every read and write goes through your API routes. Do not ask for a permissive rule.
{
"instance": "core",
"collections": [
{
"name": "club_notice_posts",
"type": "base",
"fields": [
{ "name": "club", "type": "relation", "collection": "clubs", "required": true, "maxSelect": 1 },
{ "name": "author", "type": "relation", "collection": "users", "required": true, "maxSelect": 1 },
{ "name": "body", "type": "text", "required": true, "max": 500 }
],
"indexes": ["CREATE INDEX idx_club_notice_posts_club ON club_notice_posts (club)"],
"rules": { "list": null, "view": null, "create": null, "update": null, "delete": null }
}
]
}
instance is which of our databases holds the collection. Use "core". It holds members, clubs, logins and the tables of nearly every module. We have a few others for our own services. If you think your data belongs elsewhere, say why under "Open questions" and we decide on intake.
created and updated autodate fields are added for you. Field types available: text, number, bool, email, url, date, select, relation, file, json, editor.
A select field lists its choices: { "name": "status", "type": "select", "values": ["open", "closed"], "maxSelect": 1 }.
Collections that already exist and you may relate to: users (the login record, holds role and capabilities), members (the person, holds names), clubs (the club). Relate to these by id. Do not copy their fields into your own collection.
Name it after your module (section 3, "Collection names"). If you suspect a collection for your idea already exists (lawns, events, documents), do not build a parallel one. Write it under "Open questions" in MODULE.md and we will tell you.
Stopping the same thing being written twice. indexes takes PocketBase index lines. A unique index makes the database refuse a second row with the same values, for example "CREATE UNIQUE INDEX idx_lawn_booking_slot ON lawn_booking_slots (lawn, day, start)". Your route then turns that refusal into a plain message, such as "Someone has just booked that lawn."
Data questions. You cannot see our data, so some questions only we can answer: is there already a table for lawns, which club fields exist, how to stop two people booking the same slot. Ask each one under "Open questions" in MODULE.md. We tell you which existing tables and fields you may read, and how to stop double writes.
4.8 demo-data.json
Optional. Made-up records for our public demo site, so people can see your module working without any real data. Made-up clubs and made-up people only. Never a real name, email or phone number.
{
"_about": "Made-up notices for the public demo. Made-up clubs and made-up people only. Each $ref points at a demo club or demo login. user:secretary is the Secretary at Bottlebrush Bay; user:handicapper is the Captain at Jacaranda Downs. So each author holds a posting seat at their notice's club.",
"module": "club-notices",
"records": [
{
"collection": "club_notice_posts",
"key": { "club": { "$ref": "club:demo-bottlebrush-bay" }, "body": "Lawn 2 is closed on Tuesday morning for coring. Lawns 1 and 3 are open as usual." },
"data": { "author": { "$ref": "user:secretary" } }
},
{
"collection": "club_notice_posts",
"key": { "club": { "$ref": "club:demo-bottlebrush-bay" }, "body": "Working bee this Saturday from 8 am. Bring gloves; morning tea is on the club." },
"data": { "author": { "$ref": "user:secretary" } }
},
{
"collection": "club_notice_posts",
"key": { "club": { "$ref": "club:demo-bottlebrush-bay" }, "body": "Welcome to our three new members who joined after the Come and Try day." },
"data": { "author": { "$ref": "user:secretary" } }
},
{
"collection": "club_notice_posts",
"key": { "club": { "$ref": "club:demo-jacaranda-downs" }, "body": "Pennant team list for Sunday is on the clubhouse board. Tell the Captain by Thursday if you cannot play." },
"data": { "author": { "$ref": "user:handicapper" } }
}
]
}
recordsis a list. Each one names itscollection, akeythat makes it unique, and the rest of itsdata.{ "$ref": "club:<name>" }points at a demo club, and{ "$ref": "user:<name>" }at a demo login. Use only the ones below. If you need another, ask under "Open questions".- We load it the same way every time, so loading it twice adds nothing twice.
The demo clubs: club:demo-bottlebrush-bay, club:demo-jacaranda-downs, club:demo-kookaburra-creek and club:demo-frangipani-heights. All are made up.
The demo logins:
$ref |
Who it is |
|---|---|
user:member |
A plain member at Bottlebrush Bay |
user:secretary |
The Secretary at Bottlebrush Bay |
user:handicapper |
The Captain at Jacaranda Downs (named for the handicap job a Captain does) |
user:officer |
The association's admin |
user:everything |
The association's admin who also holds a Vice President seat at Bottlebrush Bay |
So in the example, each notice's author holds a posting seat at that notice's club: the Secretary at Bottlebrush Bay, the Captain at Jacaranda Downs.
Dates that stay fresh. The demo is reset every night, so never write a fixed date. Write it relative to the day of the reset:
{ "$day": 3 }is three days after the reset day, for a date field.{ "$day": -10 }is ten days before.{ "$date": 3 }is the same day, as plain text"YYYY-MM-DD", for a text date field.{ "$weekday": "Saturday", "week": 1 }is the Saturday on or after the reset day, plus one week. A negativeweekis in the past.
5. The rules that do not bend
Each rule has a reason. The reason is why we will not waive it.
- Three gates, in code order.
requireModuleApi, thenverifyAuth, then the access check from the table in section 4.3. The middleware also gates the route, but it checks only whether the module is on. The route must defend itself. - One list, imported. Every list of tags lives in your own
src/lib/<id>.ts. Pages and routes import it. Only the manifest types a copy, and a test checks it agrees. Copies typed by hand drift apart, and that once locked real people out. - Writes trust the committee, not the cache. A write that a seat allows calls
requirePositionOrCustomAccessorrequirePositionCapability. Neveruser.positions. - Removed cards stay removed. A card every member holds is checked with
hasMemberAccessTo. pb.filter()for every filter. String-built filters are an injection hole. We test for it.- Shared database clients only.
getAdminPB()on the server.pbfrom@/lib/pocketbasein the browser, and only for the sign-in token. A second client means a second auth state and bugs nobody can reproduce. - Collections are superuser-only. All five rules
null. A permissive rule once let a member promote themselves. Your API routes are the boundary. - Club scoping comes from the verified user. Never trust a
clubIdin a request. Members of one club must not see another club's data. - No secrets in code. Read config from
process.env. List every variable you need inMODULE.md. We set them on our sites before any code that reads them goes out. - Exempt endpoints are named and commented. A cron, webhook or public endpoint goes in
exemptApiswith a comment saying who calls it. Otherwise it returns 404 the moment the module is dark. - Links to other modules use
hrefFor("<nav-item-id>")from@/modulesguarded byisModuleEnabled("<id>")from@/modules/gate. Never a hardcoded path.hrefForthrows on an unknown id, which is the point. Most menu item ids are the module's id, but not all. Name the link you want under "Open questions" and we confirm the id on intake. - The shared kit and the shared look.
@/components/uifor every control,Heroon every page,apiFetchfor every call, the shared button and colour settings. Tailwind classes for layout. No other UI library. Section 4.4. - Server components by default. Add
"use client"only when you need state or browser APIs. - Manifest imports only
./types. Build-enforced. - Ship dark.
enabled: false. We switch it on. - Do not touch shared files. No edits to the module list, the types, the middleware, the nav, the position grants, the gifting screen, our test lists, or anything outside your own folders. Ask for those lines in
MODULE.md. We make them on intake.
Member data: one member card
A member's details live on ONE member card, which the app owns. The member and an officer looking at that member see the same card and edit it the same way, with the same pencil, in place.
- No module builds its own view or editor of a member. No second profile page, no list of members with their details, no form that changes a member's name, contact details or handicap.
- Show only what your job needs about a person, usually just their name. Link to their member card for the rest.
- If your module needs a new detail about a member, ask under "Open questions" in
MODULE.md. If we agree, we add it to the member card, where everyone edits it the same way. - Never copy a member's details into your own collection. Relate to
membersorusersby id.
How to point at a member and show their name.
- Store the person as a relation to
members. In a route, the signed-in person's own record isuser.memberIdfromverifyAuth. - To show a name, read it on the server and send back only the name:
getOne(id, { fields: "id,first_name,last_name" })onmembers, joined as "First Last". Never send the whole record to the browser. - The member card for a member is the page
/profile/<member id>. The card decides for itself who may open it: a member opens their own, a club officer opens the cards of their own club's members, and the association opens all. Link to it only whenisModuleEnabled("membership")is true, and only for people who may open that card. For everyone else, show just the name.
AI
- AI calls go only through the app's shared AI helper, from
@/lib/llm, and only from a server route. No AI company's library, no AI web address and no key of your own. - The AI may not see personal details. No names, emails, phone numbers, addresses, dates of birth or member numbers in anything you send it. Send it the text of the job, not the people.
- Say in
MODULE.mdwhat the AI does, what text it is sent, and what happens when it gives no answer. The module must still work when the AI is down. We confirm which of the helper's calls you use on intake. - The call you will most likely use:
callSolid({ messages, maxTokens, jsonMode })from@/lib/llm.messagesis a list of{ role: "system" | "user", content: string }. It answers the reply as text. It tries more than once by itself, and gives up after about two and a half minutes by throwingSolidFailedError. Catch that and carry on without the AI. SetjsonMode: truewhen you want the reply as JSON. Do not pick a model: the helper does.
6. What you hand in: the package
One folder. Name it mycroquet-module-<id>/. Nothing outside these paths.
mycroquet-module-club-notices/
MODULE.md
CHECKLIST.md
collections.json
demo-data.json (optional, made-up records for our demo site)
src/
modules/club-notices.ts
lib/club-notices.ts
lib/club-notices.test.ts
app/(frontend)/club-notices/page.tsx
app/(frontend)/api/club-notices/route.ts
app/(frontend)/api/club-notices/__tests__/route.test.ts
components/club-notices/ (optional, your own components)
tests/
club-notices.staging.spec.ts
The src/ tree mirrors ours. We copy it straight in. Extra route files (such as [id]/route.ts) and extra pages go under your own club-notices folders.
MODULE.md must answer
- Name and id.
Club Notices,club-notices. - What it does. Three sentences at most.
- Who uses it. Who reads, and who does each job. Name the committee positions that do the job, if any. Which clubs.
- The member sentence. The one line that appears under the menu entry.
- Capability tags requested. For each new tag: what it lets a person do; the positions that get it automatically, or none; whether it can also be gifted; and whether it is a club-level or association-wide card.
- Nav section and order.
- Environment variables. Name, purpose, example value. Or "none".
- Collections. Or "none". Detail in
collections.json. - Exempt endpoints. Or "none". Who calls each.
- Who to contact. Name and email of the author.
- Open questions. Anything the standard did not answer. An honest question here beats a guess in the code. Data questions belong here too (section 4.7): which existing tables and fields you may read, and how to stop double writes.
Then three short sections:
- Who can do what. A table, like the one in section 4: each kind of person, and what they can and cannot do.
- What we must add on intake. The lines outside your folder that your module needs. Usually:
- each new tag, with a one-line description;
- the position grant row, for example
club_notices: ["captain", "secretary"], or "none"; - whether the tag must never be gifted;
- a label and description for the gifting screen, when the tag can be gifted;
- an icon, if yours is not in the list;
- your collections and your demo data, if any.
We also add your module to the module list, your page to our app-wide guard test, and your staging spec to our staging test list. You do not need to ask for those.
- Other things to know. explicitOnly on or off and why, if AI is used and how, the photo you want, and anything else we should read first.
CHECKLIST.md
Copy section 7. Tick each line. Under each tick, one line of evidence. "Tested" is not evidence. "Ran npx vitest run src/lib/club-notices.test.ts, 12 passed" is evidence.
7. Acceptance checklist
You check before you hand in
- ☐ Manifest at
src/modules/<id>.ts,enabled: false, imports only./types - ☐ Own capability tag requested in
MODULE.mdfor each job, or a written reason an existing tag is right - ☐ The positions that do the job, and the position grant row, are asked for in
MODULE.md, or "none" - ☐
contract.requiresnames every tag the module uses; eachnav[].requiresmatches its page guard - ☐ Every list of tags lives in
src/lib/<id>.ts; the page guard and the routes import it and type no tag; a test checks the manifest's copy agrees - ☐ Every API handler:
requireModuleApithenverifyAuththen the access check from section 4.3, in that order - ☐ A card every member holds is checked with
hasMemberAccessTo, so a removed card is refused - ☐ Every write a committee seat allows calls
requirePositionOrCustomAccess(orrequirePositionCapabilitywhen the tag can never be gifted); none trustsuser.positions - ☐ Every database filter uses
pb.filter()with placeholders - ☐ Database access only via
getAdminPB()on the server,pbfrom@/lib/pocketbasein the browser - ☐ Club scoping reads
user.clubIdfromverifyAuth, never from the request - ☐ Exempt endpoints listed and commented, or none
- ☐
collections.jsonpresent, all rulesnull, or an empty list; every name starts with your module's word - ☐ No secrets in code. Every env var named in
MODULE.md - ☐ The logic test
src/lib/<id>.test.tspasses on your own machine, run as section 4.5 shows - ☐ Route tests written in
src/app/(frontend)/api/<id>/__tests__/that prove the refusals: removed card, no seat, wrong seat, other club, unconfirmed seat, club in the request, the association's admin (we run them at intake) - ☐ A staging spec written at
tests/<id>.staging.spec.ts(we run it at intake) - ☐
Herowith a real photo on every page;apiFetchfor every call; only the shared kit for controls; no own button corners or colours - ☐ No view or editor of a member's details of your own; links go to the member card
- ☐ AI, if any, only through
@/lib/llmfrom a server route, with no personal details sent; or no AI - ☐
demo-data.jsonholds made-up records only, or is left out - ☐
MODULE.mdsays whether each card is club-level or association-wide - ☐ No file outside the package layout in section 6
- ☐
MODULE.mdanswers all eleven questions and has the three short sections
We check on intake
- ☐ The ids, tags and collection names are not already taken
- ☐ Type check passes with your files and our intake lines in place
- ☐ Our whole test suite passes with your route tests in it, including the app-wide guard and module tests
- ☐ Your staging spec runs on our staging site
- ☐ Dark away from the staging host: on any address not in
allowedHosts, the page redirects, the API returns 404 and there is no menu entry - ☐ Switched on at the staging host: works end to end, for each kind of person in your table
- ☐ We send you the results of all of the above
- ☐ Cross-club isolation probed
- ☐ Security checks run over your API routes
- ☐ The association signs off before anything reaches live
8. The prompt to give your AI
Paste this, then paste this whole document under it.
You are building a module package for MyCroquet, the Croquet Queensland member app.
Read the standard below in full before writing anything.
Build a module package for: <describe your idea in two or three sentences>
Rules:
- Follow the standard exactly. Where the standard shows a shape, copy the shape.
The worked example in section 4 is the shape to copy.
- Output the complete folder described in section 6. Every file in full. No placeholders.
- Do not create or edit any file outside the package layout. Anything shared that the
module needs goes in MODULE.md under "What we must add on intake".
- Put every list of tags in src/lib/<id>.ts. The page guard and the routes import it
and never type a tag string. The manifest types a copy, and a unit test checks it agrees.
- contract.requires names every tag the module uses. nav requires matches the page guard.
- Every API handler opens with the three gates in the order shown in section 4.3, and
picks its access check from the table there. If committee positions do the job, ask
for a position grant row; do not invent a role.
- Write route tests in src/app/(frontend)/api/<id>/__tests__/ that prove the refusals
listed in section 4.5, not only the happy path. Route tests and the staging spec
need the real app, so they cannot run here: write them anyway, and the MyCroquet
team runs them. The logic test in src/lib can run here (section 4.5): run it.
- Never guess at existing data (tables, club fields, how to stop double writes).
Ask under "Open questions".
- Use Hero with a real photo, apiFetch for every call, and the shared kit for every
control. No button corners or colours of your own. No new look.
- Never build a view or editor of a member's details. Link to the member card.
- AI only through @/lib/llm from a server route, with no personal details. Or no AI.
- Say in MODULE.md whether each card is club-level or association-wide.
- Start every collection name with the module's own word.
- Demo data, if any, is made-up only.
- Fill CHECKLIST.md from section 7 with one line of real evidence per tick.
- If the standard does not answer a question, write the question in MODULE.md
under "Open questions" instead of guessing.
9. Glossary
Capability tag. A short string like club_notices that a person holds. Gates check tags. A person gets tags from their role, from their seat on their club's committee, and from gifts.
Role. One of five: player, club_captain, club_membership, curator, caq_admin. A role is a small bundle of tags.
Position. A seat on a club's committee: president, secretary, treasurer, vice_president, captain, committee_member, registrar. The club keeps its committee in MyCroquet. A seat counts only at the person's own club, and only once the club has confirmed it.
Position grant. A row that says which positions get a tag automatically, such as club_notices: ["captain", "secretary"]. You ask for it in MODULE.md. We add it.
Gift. Giving a tag straight to a named person, for someone who does the job without the seat. Each gift is for one person; there is no limit on how many. A tag given by a seat can usually be gifted as well.
Card. This word has four meanings here. Read it by its context.
- A module's card: how a member sees a module in their menu and on My cards. Members can hide or remove most cards. A removed card is gone for that person everywhere.
- A job's card: the tag for one job, as a member sees it. "Gift the posting card" means gift the
club_noticestag. - A front-page tile: the picture box on the front page. There is one per section, not one per module (section 3, rule 5).
- The member card: the one place a member's details are shown and edited (below).
My cards. The page where a member sees their cards and can hide or remove them.
CAQ. Croquet Association Queensland, "the association" in this document. It runs MyCroquet.
Intake. What we do when your package arrives: copy it in, add the shared lines it asks for, run the tests and try it on staging.
Switched on, lit. The same thing: the module counts as enabled at that address, so its menu entry, pages and API answer. "Dark" is the opposite.
Edge middleware. Code that runs before every page and API call, to check whether the module is switched on. It can load almost nothing from the app, which is why the manifest imports only ./types.
Curate, curator. Choosing which stories and photos the association shows. The curate tag belongs to the people who do that.
Providore. The club member who looks after catering. Clubs may list one on their committee. The seat gives no tags.
Come & Try, Come & Try Control. Come & Try is free taster sessions where clubs welcome people new to croquet. Come & Try Control is the association's own control room for those sessions, for association staff only. Do not put a club module in it.
Removal marker. What the app stores when a member removes a card: no:module:<module id> or no:<tag>, sometimes followed by @ and the tags that bring it back. Section 4.3.
Club-level card. A card that works only inside the holder's own club. Gifting by a club's president or secretary is being added.
Association-wide card. A card that reaches every club. Only the association gives it.
explicitOnly. A switch on a requirement that stops the association's admin passing automatically. Only a person actually given the tag gets in.
Member card. The one place a member's details are shown and edited. The member and an officer see the same card. No module builds its own.
Shared AI helper. The one way a module may call an AI, @/lib/llm, from a server route. It may not be sent personal details.
Manifest. The one file that declares a module. Section 3.
Dark ship. Handing in with enabled: false. The module is in the code but invisible. We switch it on later.
Staging. Our test copy of MyCroquet, with made-up data. Your module is tried there before live. Its address may change.
Allowed host. An address where a dark module counts as switched on. We use it to test on staging. Everywhere else the module stays dark.
Sign-off. The association's yes. Adding a module always needs it before the module reaches live.
Demo data. Made-up records in demo-data.json for our public demo site. Never real people.
PocketBase. The database. One record per row. Collections instead of tables. Five API rules per collection control who may list, view, create, update, delete. Ours are all null, which means server only.
Gate. A check that returns early. Module gate: is the module on? Auth gate: who is this? Access gate: may they do this?
Package. The one folder you hand in. Section 6.
Changes to this standard
| Date | Change |
|---|---|
| 2026-10-26 | Version 2.0. Jobs from committee positions; one list of tags, imported; contract.requires names every tag; hasMemberAccessTo, requirePositionOrCustomAccess and explicitOnly; the shared look; one member card; AI rules; demo data and route tests in the package; club-level or association-wide cards; what happens after you send it; taken names, icons and the kit list brought up to date; the worked example rebuilt and tested against the real app. |
| 2026-09-04 | Version 1.0. First public version. Native modules only; no embedded path. |