A copyright-free sample groupware portal: Laravel 13 + Inertia v3 (React) + PostgreSQL. The UI is Japanese throughout.
- PHP 8.4+, Composer
- Node 22+
- Docker (for the bundled PostgreSQL container)
docker compose up -d # PostgreSQL on 127.0.0.1:5432
composer setup # install, .env, app key, migrate, seed, npm install, build
composer run dev # serve + queue worker + vite + reverb + logscomposer run dev runs a worker on the sandbox,default queues. It
matters: script runs and notifications are queued jobs, so without it they
stay in 待機中 forever.
Script tools need two more steps, because the sandbox ships off:
composer sandbox:images # build the two container imagesThen set SANDBOX_DRIVER=docker in .env - and on macOS
SANDBOX_REQUIRE_ROOTLESS=false too, because Docker Desktop is not a rootless
daemon and the runner refuses one otherwise. Skip this and every script tool
fails with SANDBOX_DRIVER is 'none' on this host; link and embed tools are
unaffected. The sandbox explains why none is the default.
If 5432 is already in use on your machine, copy .env.example to .env first and
set DB_PORT to a free port (5433, say) - compose publishes the container on
whatever DB_PORT says, and Laravel connects to the same one.
composer setup is safe to run again: the seeders skip what is already there.
Open http://127.0.0.1:8000 and sign in.
| Login ID | Role | Password |
|---|---|---|
test |
member | password |
manager |
manager | password |
admin |
admin | password |
/tools starts empty, because a tool is something someone registers rather
than code in this repository. To see the platform with something in it:
php artisan demo:seed # publish the demo catalog; --fresh to redo itThat adds demo, demo-manager and demo-admin (also password) and walks
a handful of sample tools through the real approval flow. See
demo/README.md for what it publishes and how to add to it.
After pulling changes into an environment that is already set up:
php artisan migrate
php artisan db:seed --forcecomposer setup copies .env.example and the app runs as it is. These are the
values worth a second look:
| Key | Why |
|---|---|
DB_PORT |
Compose publishes Postgres on it; change it when 5432 is taken |
SANDBOX_DRIVER |
none only queues script runs. bubblewrap locally, docker on the runner host, or script tools never finish |
REVERB_APP_ID / _KEY / _SECRET, VITE_REVERB_* |
Live updates. Leave blank and every screen falls back to polling - nothing breaks, it is just slower |
CATALOG_DEPARTMENTS |
The 所属 allowlist, comma separated. Blank means the field is free text |
CATALOG_SUBMISSIONS |
Who may register a tool: all, admin (the development team only) or none (nobody, and the screens are gone) |
CATALOG_REQUESTS |
The request queue at /tools/requests, for a tool that does not exist yet. Off and the screens are gone |
PASSKEYS_USER_HANDLE_SECRET |
Defaults to APP_KEY. Set it to its own fixed value if APP_KEY will ever be rotated, or passkeys stop resolving |
LOG_CHANNEL |
The system screen tails whichever channel this names, so a daily or custom path is followed, not assumed |
| Path | What lives there |
|---|---|
routes/web.php, routes/settings.php, routes/tools.php |
Routes, split by area |
app/Http/Controllers/Tools/ |
The tool module |
database/migrations/ |
tools, tags, tag_tool, tool_submissions, tool_requests |
config/catalog.php |
The 所属 list and the two feature switches |
app/Sandbox/ |
The sandbox runners script tools execute in |
config/sandbox.php, docker/sandbox/ |
Sandbox limits, driver and container images |
resources/js/pages/ |
Inertia page components |
demo/ |
The demo catalog, published by php artisan demo:seed |
.ai/rules/ |
Decisions and traps worth knowing before editing |
/tools collects the in-house tools. Nothing is pushed as code - a tool is a
row in the tools table.
- Kinds:
linkopens a URL (external https or a portal path),embedframes an external https page inside the tool's own screen,scriptruns a script in the sandbox. - The catalog filters on status, category and 所属. Nothing is hidden by a rule
the screen does not show: the filter opens with every status but 非推奨
ticked, so deprecated tools stay for reference one box away. Ticks are kept
on the account (
users.catalog_filters) as they are made - debounced, and flushed if the page is left first - so the catalog opens the way it was left. A saved value whose tag was since renamed or merged is dropped rather than left to hide the whole catalog. - Requests (
tool_requests, 依頼): somebody who cannot build a tool describes what they need and the development team triages it - open → accepted → in progress → delivered, or declined / duplicate / withdrawn. A request is visible to its requester's own 所属 and to the team, nobody else. Approving a submission filed against a request is what delivers it, so the tool going live is what closes it. - Submissions (
tool_submissions, 登録): registering a tool and changing what it does (URL / script / runtime / inputs) or retiring it go through draft → pending → endorsed → approved / rejected. Display fields - name, summary, description, icon, tags - are edited in place by the owner without review. - Versions: every approval stamps the tool with the approval date to the
minute (
202608271037, then202608271037.2for a second approval within the same minute) and records who requested, endorsed and approved it. The approved submissions are the history. - Notifications: a submission messages every reviewer (inbox at
/inbox, bell in the header) with a link to/admin/approvals/{id}; the decision messages the requester back. Reverb pushes updates live; screens also poll every minute as a safety net, so a dev box withoutreverb:startstill works. - Roles:
users.roleismember,manageroradmin. The requester's department manager endorses first, then a system admin publishes. An admin may approve straight from the first stage, and a department with no manager falls through to the admins. - Leaving: a person is retired, never deleted. Their inbox, bell and
passkeys go, they can no longer sign in, and the row stays - scrubbed of
name and address - so every approval still says who made it, now as
退職したユーザー. Tools they owned pass to a successor, since a tool nobody owns can only be touched by an administrator. Closing your own account from/settings/profiledoes the same and hands the tools to your department's manager; the last administrator is refused. - Halves you can switch off:
CATALOG_SUBMISSIONSandCATALOG_REQUESTSdecide which of the two flows this deployment runs. A flow that is off is absent, not forbidden - its routes answer 404 and its menu entries are gone. Who may file inside an enabled flow is a separate, 403 question, which is howCATALOG_SUBMISSIONS=admintakes requests from everyone while only the development team registers tools.
/admin covers every table without a database client:
| Screen | What it edits |
|---|---|
/admin/approvals |
The two review stages (managers see their own department) |
/admin/requests |
The development team's queue: accept, decline, merge, deliver |
/admin/users |
Roles and 所属, and retiring someone who has left |
/admin/tools |
Every row, deleted ones included: deprecate, restore, purge |
/admin/tags |
Rename and merge category tags |
/admin/runs |
Browse, delete, prune sandbox runs |
/admin/system |
Queues, workers, sandbox, Reverb, recent runs, log tail |
Script tools never run inside the app. A queued RunToolJob on the sandbox
queue re-reads the approved source, checks its hash against the one the run
was requested with, and hands it to a SandboxRunner:
SANDBOX_DRIVER |
Where | Isolation |
|---|---|---|
docker |
the runner host | throwaway container: --network none, read-only root, uid 65534, all caps dropped, memory/cpu/pid limits, timeout kill |
bubblewrap |
a dev box without Docker | fresh namespaces, no network, read-only root, private /tmp; memory via ulimit. Not for production |
fake |
tests | never executes anything |
none |
the web host | only queues runs; throws if a job ever executes here |
A script tool declares whether it needs the internet (config.network:
none, the default, or internet). Reviewers see the choice highlighted on the
approval screen and can run the submitted script in the sandbox before deciding;
the runner attaches an internet tool to SANDBOX_INTERNET_NETWORK (default
bridge - point it at a bridge whose egress you control) and everything else to
--network none.
Inputs reach the script as a JSON file named by $TOOL_INPUTS; stdout is the
result, capped at SANDBOX_OUTPUT_BYTES. Runs are rate limited per user
(SANDBOX_RATE_LIMIT per minute) and pruned after SANDBOX_RUN_RETENTION_DAYS
by the scheduled carrot:prune-runs.
Run the same codebase on a separate host that serves no HTTP and only works the
queues: php artisan queue:work --queue=sandbox,default. It needs the database,
queue and storage credentials and nothing else.
- Create an unprivileged account, e.g.
carrot-runner, with/etc/subuidand/etc/subgidranges. - Install rootless Docker for that account
(
dockerd-rootless-setuptool.sh install), then runloginctl enable-linger carrot-runnerso its daemon survives logout. Never add the account to thedockergroup - a root dockerd socket is root, andDockerSandboxRunnerrefuses to start unlessdocker inforeports rootless (SANDBOX_REQUIRE_ROOTLESS=falseonly on a dev box). - Enable cgroup v2 delegation for the user (
systemddrop-in withDelegate=cpu cpuset io memory pids) or the--memory/--cpus/--pids-limitflags are ignored. - Build the images in CI (
docker/sandbox/README.md) and pull them on the host; the runner never builds. - Set
SANDBOX_DRIVER=dockerandDOCKER_HOST=unix:///run/user/<uid>/docker.sockin the runner's.env; the web host keepsSANDBOX_DRIVER=none.
Locally, composer run dev runs a worker on sandbox,default and Reverb next to
the web server, so SANDBOX_DRIVER=bubblewrap (or docker with a rootless
daemon) makes script tools work end to end on one machine. Without Reverb the
screens simply poll.
composer test # Pint, PHPStan, Pest
npm run types:check # tsc
npm run lint # eslintCI runs all of it on every push and pull request - composer setup then
composer ci:check, on PHP 8.4 and Node 22 against a Postgres 18 service.
Pest, feature tests throughout, a few seconds end to end. They exercise HTTP and Inertia props rather than calling classes directly, so a route, a policy and a page prop are all held down at once.
| Suite | What it holds down |
|---|---|
tests/Feature/Tools/ |
The catalog: what is listed, the tag groups and their counts, and the rule that an embed only ever frames an external https origin. The request flow: drafts, per-kind validation, withdrawal, change and retire requests, and display fields edited in place without review. Requests: the 所属 they are stamped with and who may read them, and that a flow switched off answers 404 while a flow someone may not use answers 403 |
tests/Feature/Admin/ |
Retiring a person: the history keeps naming them anonymously, their inbox and passkeys go, their tools reach a successor, and the last administrator is refused. Two-stage approval, version stamping (including twice in one minute), slug uniqueness, rejection. Request triage: accept, decline with a reason, merge a duplicate, and that approving a submission filed against a request delivers it. The admin screens: roles and 所属, the trash and purge, tag rename/merge, run pruning, and the system status snapshot |
tests/Feature/Sandbox/ |
Every isolation flag of the docker command, the output cap, the network choice, the source-hash check that refuses to run what was not approved, per-user rate limiting, run visibility and pruning |
tests/Feature/Inbox/ |
Who gets messaged and notified at each stage, read state, and that a message is only ever visible to its recipient |
tests/Feature/DemoSeedTest.php |
That demo:seed publishes through the real approval flow, files its requests the same way and lets the tool that answers one close it, is safe to re-run, and refuses production |
tests/Feature/Auth/, Settings/ |
Login by username, registration rules, password reset, two-factor and passkeys - inherited from the starter kit |
Two suites are opt-in and skip unless the tooling is there:
BubblewrapRunnerTest needs bwrap installed, and DockerRunnerTest needs
SANDBOX_DOCKER_TESTS=1 and a working Docker. Everything else runs anywhere,
against the carrot_test database, which docker compose up -d creates
alongside carrot the first time it builds the volume. On a volume that
predates it:
docker exec carrot-pgsql psql -U carrot -d carrot -c 'CREATE DATABASE carrot_test OWNER carrot'The suite runs on Postgres because production does, and SQLite let a bug
through: it reads JSON out of a text column happily, so a query Postgres
rejects with operator does not exist: text ->> unknown passed every run.