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

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:

  1. Their role. Roles: player, club_captain, club_membership, curator, caq_admin. A role is a small bundle of tags. Every role gives member.
  2. Their seat on their club's committee. Each club keeps its committee in MyCroquet. The positions are president, secretary, treasurer, vice_president, captain, committee_member and registrar. A club may also list a providore, 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

  1. It arrives switched off. Your manifest says enabled: false. It stays that way.
  2. We copy it in and test it. We add the few shared lines your MODULE.md asks 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 is staging.my.croquetwade.com. That address may change. If it does, we update your allowedHosts on intake. You do not need to send a new version.
  3. 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.
  4. It reaches live still switched off. Nobody sees it yet.
  5. 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.

  1. It lives at src/modules/<id>.ts and exports one const of type ModuleManifest.
  2. It imports nothing except ./types. No React. No Next. No icons. No helpers. Not your own @/lib file either. The file is bundled into edge middleware. The build fails if you break this.
  3. enabled is false. Always. We switch it on, not you.
  4. allowedHosts may contain our staging address, today "staging.my.croquetwade.com". On that address the module counts as switched on, even though enabled is false. 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.
  5. 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.
  6. 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: use member.
  7. 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.
  8. order is where you sit inside your section. Pick any number from 40 to 99. We renumber on intake if it clashes. section is the audience who reads the entry. If readers and writers differ, pick the readers.
  9. contract.requires lists 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 an allowedHosts address (rule 4). Your routes are the real gate (section 4.3).
  10. nav[].requires decides 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.
  11. 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.
  12. 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 (explicitOnly prop on CapabilityGuard) and the routes (hasExplicitCapability from @/modules/capabilities). Say why in MODULE.md.
  13. Club-level or association-wide. Every card is one or the other, and MODULE.md must 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:

  1. module gate
  2. authenticate
  3. 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:

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.

Prop contracts you will need most:

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:

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().

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" } }
    }
  ]
}

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:


5. The rules that do not bend

Each rule has a reason. The reason is why we will not waive it.

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.

How to point at a member and show their name.

AI


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

  1. Name and id. Club Notices, club-notices.
  2. What it does. Three sentences at most.
  3. Who uses it. Who reads, and who does each job. Name the committee positions that do the job, if any. Which clubs.
  4. The member sentence. The one line that appears under the menu entry.
  5. 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.
  6. Nav section and order.
  7. Environment variables. Name, purpose, example value. Or "none".
  8. Collections. Or "none". Detail in collections.json.
  9. Exempt endpoints. Or "none". Who calls each.
  10. Who to contact. Name and email of the author.
  11. 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:

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

We check on intake


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.

  1. 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.
  2. A job's card: the tag for one job, as a member sees it. "Gift the posting card" means gift the club_notices tag.
  3. A front-page tile: the picture box on the front page. There is one per section, not one per module (section 3, rule 5).
  4. 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.