RFID tag registry for Jamboree26. Stores which group a physical tag belongs to, so lost tags can be traced back to their owner and other J26 services can resolve a tag id programmatically.
Runs as a micro-frontend in an iframe inside the j26-app shell, mounted at
/_services/tags.
- Lookup page (
/) — type or NFC-scan a tag id, see which group it belongs to and where. For lost-and-found staff. - Admin UI (
/admin) — CRUD over tags, groups and members, search/filter, and CSV bulk import. - Machine API (
/api/tags) — look tags up and register new ones, authenticated with a single static API key. For hardware readers and other services.
A tag stores only references — a group number, a member number, or both. The group name and the coordinates live in two lookup tables and are resolved when a tag is read:
Tag id (UID) → groupId? memberNo?
Group groupId → name, lat?, lng?
Member memberNo → lat?, lng?
Two consequences worth knowing:
- Correcting a camp site is one edit. Moving a group means editing that one Group row, not every tag pointing at it.
- References are soft, not foreign keys. A tag can be registered before its
group or member has been imported. Until then the tag resolves with
resolved: falseand shows the bare number; it links up automatically once the lookup row arrives. Deleting a group or member does not delete tags — they just become unresolved, and the UI reports how many.
Coordinates prefer the group's camp site, falling back to the member's own
position when the group has none. coordinateSource says which was used.
Requires Docker (for PostgreSQL) and the j26 CLI for the shared-origin proxy.
cp .env.local.template .env.local # then fill in the values
docker compose up -d # PostgreSQL on :5432
pnpm install
pnpm db:migrate # apply migrations
pnpm dev # http://0.0.0.0:3002Because the app must be served under its base path with the shell's auth cookie
on the same origin, use the CLI rather than hitting :3002 directly. Add a local
service entry to your .j26.local.yaml:
localServices:
tags:
name: tags
path: /_services/tags
port: 3002
rewritePath: falsethen j26 up and open https://local.j26.se/_services/tags.
pnpm dev # Dev server (port 3002, host 0.0.0.0)
pnpm build # Production build (Nitro bundle in .output/)
pnpm test # Vitest
pnpm check # Biome lint + format
pnpm typecheck # tsc --noEmit
pnpm db:generate # Regenerate the Prisma client after schema changes
pnpm db:migrate # Create + apply a migration
pnpm db:studio # Prisma Studio
pnpm db:reset # Drop and re-migrate| Variable | Purpose |
|---|---|
DATABASE_URL |
PostgreSQL connection string |
KEYCLOAK_DISCOVERY_URL |
OIDC discovery document; supplies the issuer and JWKS URL used to verify JWTs |
TAGS_API_KEY |
Single static key for the machine API (read and register) |
Authenticate with either header:
Authorization: Bearer <TAGS_API_KEY>
X-API-Key: <TAGS_API_KEY>
group, lat and lng are resolved from the lookup tables, not stored on the
tag. resolved: false means a referenced group or member has not been imported yet.
{
"tags": [
{
"tagId": "04A21B",
"groupId": "1234",
"memberNo": "555001",
"group": "Bromma Scoutkår",
"lat": 57.7089,
"lng": 11.9746,
"coordinateSource": "group",
"resolved": true,
"unresolved": [],
"updatedAt": "2026-07-27T09:00:00.000Z"
}
]
}The same object, or 404 {"error":"not_found"}.
Register a new tag. tagId is required, plus at least one of groupId and
memberNo. The group name and coordinates are not accepted here — they belong to
the group and member records.
curl -X POST https://app.jamboree.se/_services/tags/api/tags \
-H "X-API-Key: $TAGS_API_KEY" -H 'Content-Type: application/json' \
-d '{"tagId":"04:A2:1B","groupId":"1234","memberNo":"555001"}'201 with the created tag, resolved — so a resolved: false in the response tells
you the tag was stored but its group/member is not in the lookup tables yet. That
is allowed on purpose; registration never blocks on import order.
This is not an upsert — re-registering an existing id returns
409 {"error":"already_exists","tagId":"04A21B"} rather than overwriting, since
that is far more often a mistake than an intent to replace. Use the admin UI to
edit an existing tag.
Tags created this way are attributed to Maskin-API in the admin UI, since there
is no Keycloak user on this path.
Machine clients should match on the error code, not the HTTP body prose.
| Status | Code | Meaning |
|---|---|---|
| 400 | invalid_json, invalid_body |
Body was not a JSON object |
| 400 | invalid_tag_id |
Missing or unusable tagId |
| 400 | missing_reference |
Neither groupId nor memberNo given |
| 400 | invalid_group_id, invalid_member_no |
Malformed reference number |
| 400 | moved_field |
Sent group, lat or lng — see fields in the body |
| 401 | — | Missing or wrong API key |
| 404 | not_found |
No such tag |
| 405 | method_not_allowed |
See the Allow header |
| 409 | already_exists |
That tag id is already registered |
moved_field exists so a client still sending a group name and coordinates fails
loudly instead of believing it had stored them.
Tag ids are normalised (uppercased, separators stripped), so 04:a2:1b,
04-A2-1B and 04a21b all refer to the same tag on both read and write.
The Keycloak cookie does not open these endpoints, and the API key does not
open the UI or /api/me.
Declared as code in j26-keycloak
under the j26-tags client, and read from resource_access["j26-tags"].roles.
| Role | Grants |
|---|---|
admin |
Everything |
tags:write |
Create/edit/delete tags, groups and members, and run imports. Implies lookup. |
tags:read |
Look up and search only |
A user with none of these gets 401 from /api/app-config, which hides the tool
from the shell navigation entirely.
Three separate imports, one per table. All of them: header row required, column
order free, , or ; as the delimiter, and , accepted as a decimal separator.
Blank, -, null and n/a all mean "not set".
Encoding is handled for you. Uploads are decoded by sniffing the bytes — BOM first, then strict UTF-8, falling back to Windows-1252 (what desktop Excel's plain "CSV" export writes). UTF-16 works too. So a file saved from Excel any of the usual ways imports with å ä ö intact, and the form reports which encoding it used when it isn't UTF-8.
The one case that can't be rescued is a file whose characters were already
destroyed before upload — if å arrives as a literal ? or �, the information
is gone. The import form detects this and tells the operator to re-save from Excel
as CSV UTF-8 (comma delimited) rather than plain CSV. The downloadable
samples all carry a UTF-8 BOM so Excel opens them correctly in the first place.
Each is previewed first and committed as a single transaction — if any row is
invalid, nothing is written. Ready-made samples live in public/ and are
linked for download from each import page.
Tags — tagId required, plus at least one of groupId/memberNo per row:
tagId;groupId;memberNo
04:A2:1B;1234;555001
CAFE0001;1234;
DEADBEEF;;901234Groups — groupId and name required, coordinates optional:
groupId;name;lat;lng
1234;Bromma Scoutkår;57,7089;11,9746
5678;Lund Scoutkår;;Members — memberNo required, coordinates optional:
memberNo;lat;lng
555001;55,7047;13,1910
901234;;Rows referencing a group or member that hasn't been imported yet are imported anyway and flagged in the preview — import order doesn't matter, and the tags link up once the lookup rows arrive.