Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ff85ed31ad |
@@ -1,54 +0,0 @@
|
|||||||
# AGENTS.md
|
|
||||||
|
|
||||||
This repository (`groombook/api`) is part of the GroomBook application stack. The
|
|
||||||
authoritative process, quality bar, and safety rules live in the shared
|
|
||||||
[`groombook/org`](https://git.farh.net/groombook/org) skills repository. Read
|
|
||||||
those first; this file is only a pointer.
|
|
||||||
|
|
||||||
## Authoritative skills
|
|
||||||
|
|
||||||
- **SDLC (branching, PRs, phases, handoffs):**
|
|
||||||
[`groombook/org/skills/sdlc/SKILL.md`](https://git.farh.net/groombook/org/src/branch/main/skills/sdlc/SKILL.md)
|
|
||||||
- **Coding standards (priority ordering, PR discipline, tests, no-hardcoded-values, CalVer):**
|
|
||||||
[`groombook/org/skills/coding-standards/SKILL.md`](https://git.farh.net/groombook/org/src/branch/main/skills/coding-standards/SKILL.md)
|
|
||||||
- **Safety (no plaintext secrets, no direct `kubectl apply` to `groombook`, no self-merge, board approval for destructive actions):**
|
|
||||||
[`groombook/org/skills/safety/SKILL.md`](https://git.farh.net/groombook/org/src/branch/main/skills/safety/SKILL.md)
|
|
||||||
|
|
||||||
For human contributors and humans reviewing agent work, see
|
|
||||||
[`CONTRIBUTING.md`](./CONTRIBUTING.md) in this repo for the phase-by-phase PR
|
|
||||||
flow and the `uat→main` merge-gate policy summary.
|
|
||||||
|
|
||||||
## Non-negotiable operational rules
|
|
||||||
|
|
||||||
These mirror the org skills; they are restated here so any agent landing in
|
|
||||||
this repo sees them without a cross-repo fetch.
|
|
||||||
|
|
||||||
- **All changes go through a PR.** Never push directly to `dev`, `uat`, or `main`.
|
|
||||||
- **Branch strategy:** `feature/<name>` → `dev` → `uat` → `main`. Engineers
|
|
||||||
always target `dev` first.
|
|
||||||
- **No self-merge contract.** The engineer who opened a PR clicks merge only
|
|
||||||
after the named reviewer (CI / QA / UAT / Security / CTO per phase)
|
|
||||||
approves. Issue-thread QA / UAT / security approvals do **not** clear the
|
|
||||||
Gitea `required_approvals` gate on `uat→main` — only a Gitea **Approve**
|
|
||||||
click from a member of the `approvals_whitelist_username` does. On this
|
|
||||||
repo that whitelist is `["gb_flea", "gb_dogfather"]` (engineer team).
|
|
||||||
Board-level accounts cannot give the Approve click by policy.
|
|
||||||
- **Always include `cc @cpfarhood`** at the bottom of every PR body for
|
|
||||||
board visibility (not as a reviewer).
|
|
||||||
- **Secrets in code are forbidden.** Use Bitnami Sealed Secrets; never commit
|
|
||||||
plaintext. See the `safety` skill.
|
|
||||||
- **Production (`groombook` namespace) is Flux-managed.** Never
|
|
||||||
`kubectl apply` directly. Infrastructure changes go through PRs in
|
|
||||||
`groombook/infra`.
|
|
||||||
|
|
||||||
## Local development
|
|
||||||
|
|
||||||
See the repo's own README, package scripts, and CI workflow. The
|
|
||||||
authoritative pipeline (Gitea Actions, image build, deploy hooks) is the
|
|
||||||
shared `groombook/infra` overlay; do not reimplement it here.
|
|
||||||
|
|
||||||
## When uncertain
|
|
||||||
|
|
||||||
If a task conflicts with the org skills, **the org skills win**. Open an
|
|
||||||
issue in `groombook/org` to propose a change rather than encoding a local
|
|
||||||
exception.
|
|
||||||
-117
@@ -1,117 +0,0 @@
|
|||||||
# Contributing to `groombook/api`
|
|
||||||
|
|
||||||
Thanks for contributing. This document is the human-facing companion to
|
|
||||||
[`AGENTS.md`](./AGENTS.md) and the authoritative
|
|
||||||
[`groombook/org`](https://git.farh.net/groombook/org) skills. The org skills
|
|
||||||
govern; this file is a quick-reference for the human/agent PR flow in this
|
|
||||||
repo.
|
|
||||||
|
|
||||||
## Branch strategy
|
|
||||||
|
|
||||||
Three long-lived branches; one PR per promotion step.
|
|
||||||
|
|
||||||
| Branch | Environment | Who merges | Prerequisites for merge |
|
|
||||||
|---------|-------------|-----------|-------------------------|
|
|
||||||
| `dev` | Dev | Engineer | CI passes |
|
|
||||||
| `uat` | UAT | Engineer | QA code review approval |
|
|
||||||
| `main` | Production | Engineer | UAT validation + CTO Gitea Approve when the `uat→main` merge-gate policy applies (see below) |
|
|
||||||
|
|
||||||
Engineers always target `dev` first. Feature branches: `<agent-name>/<short-description>`.
|
|
||||||
|
|
||||||
## Phase-by-phase PR flow
|
|
||||||
|
|
||||||
### Phase 1 — Dev
|
|
||||||
|
|
||||||
1. Branch from `dev`: `git checkout -b <name>/<short-description> origin/dev`.
|
|
||||||
2. Write code + tests. Run unit tests, type check, and lint locally (or rely on CI).
|
|
||||||
3. Open a PR against `dev`:
|
|
||||||
```bash
|
|
||||||
tea pr create --base dev --title "..." --body "..."
|
|
||||||
```
|
|
||||||
Include `cc @cpfarhood` at the bottom of the body for board visibility.
|
|
||||||
4. CI must pass. CI green → engineer self-merges.
|
|
||||||
5. CI builds and deploys to Dev automatically.
|
|
||||||
|
|
||||||
### Phase 2 — UAT promotion
|
|
||||||
|
|
||||||
1. Open a PR from `dev` to `uat`.
|
|
||||||
2. CI must pass.
|
|
||||||
3. **QA (Lint Roller)** reviews and approves on the Gitea PR.
|
|
||||||
4. QA approved → engineer self-merges.
|
|
||||||
5. CI builds and deploys to UAT automatically.
|
|
||||||
|
|
||||||
### Phase 3 — UAT regression + Security review
|
|
||||||
|
|
||||||
1. **UAT (Shedward Scissorhands)** runs full regression against UAT — every
|
|
||||||
feature, old and new, no exceptions.
|
|
||||||
2. **Security (Barkley Trimsworth)** reviews the changes.
|
|
||||||
3. Failures in either gate bounce back to Phase 1.
|
|
||||||
|
|
||||||
### Phase 4 — Production promotion (`uat → main`)
|
|
||||||
|
|
||||||
This is the gate the org PR
|
|
||||||
[`groombook/org#13`](https://git.farh.net/groombook/org/pulls/13) defines.
|
|
||||||
The full rule is in
|
|
||||||
[`groombook/org/skills/sdlc/SKILL.md`](https://git.farh.net/groombook/org/src/branch/main/skills/sdlc/SKILL.md)
|
|
||||||
and
|
|
||||||
[`groombook/org/skills/coding-standards/SKILL.md`](https://git.farh.net/groombook/org/src/branch/main/skills/coding-standards/SKILL.md);
|
|
||||||
the summary is below.
|
|
||||||
|
|
||||||
**The CTO Gitea Approve click is NOT the default gate.** Once the four
|
|
||||||
pre-gates (QA, UAT deploy, UAT regression, security) are green, the engineer
|
|
||||||
self-merges.
|
|
||||||
|
|
||||||
**A CTO Gitea Approve click IS required** only for PRs in one of three
|
|
||||||
categories:
|
|
||||||
|
|
||||||
1. **Novel auth / session paths** — login, OIDC, OOBE, session middleware,
|
|
||||||
token issuance, password reset, MFA, new auth provider integrations.
|
|
||||||
Routine auth-gated UI (button styling, error messages, form layout) is
|
|
||||||
**not** in this category.
|
|
||||||
2. **Infra / prod-affecting merges** — deploys, infra manifests, secrets,
|
|
||||||
GitOps overlays, CI/CD, `main` branch protection, production
|
|
||||||
routing/ingress, prod state mutations. All Phase 5 infra overlay PRs in
|
|
||||||
`groombook/infra` require CTO Gitea Approve without exception.
|
|
||||||
3. **Risk-flagged merges** — `risk:cto-approve` label, or explicit CTO/CEO
|
|
||||||
sign-off request in the PR or issue thread.
|
|
||||||
|
|
||||||
The engineer opens the `uat→main` PR, classifies it against the three
|
|
||||||
categories above, and adds `cc @cpfarhood`. If the PR is in scope, the CTO
|
|
||||||
clicks Approve; once approved (and the four pre-gates are green), the
|
|
||||||
engineer merges.
|
|
||||||
|
|
||||||
### Phase 5 — Production deployment
|
|
||||||
|
|
||||||
A separate PR in `groombook/infra` bumps the overlay image tag for prod.
|
|
||||||
Handed to QA (Lint Roller) for review, then self-merged by the engineer.
|
|
||||||
|
|
||||||
## The four pre-gates (uat→main)
|
|
||||||
|
|
||||||
A `uat→main` PR is mergeable when **all four** are green:
|
|
||||||
|
|
||||||
1. **QA code review** — done on the dev→uat promotion PR.
|
|
||||||
2. **UAT deploy** — the UAT image built from the uat tip is live in UAT.
|
|
||||||
3. **UAT regression** — Shedward's full-feature UAT pass is green (no
|
|
||||||
pre-existing defects, no new defects).
|
|
||||||
4. **Security review** — Barkley's security code review is green.
|
|
||||||
|
|
||||||
Issue-thread QA / UAT / security approvals do **not** clear the Gitea
|
|
||||||
`required_approvals` gate. Only a Gitea **Approve** click from a member of
|
|
||||||
the `approvals_whitelist_username` for `main` clears it. In this repo that
|
|
||||||
whitelist is the engineer team (`gb_flea`, `gb_dogfather`).
|
|
||||||
|
|
||||||
## Style, tests, and quality bar
|
|
||||||
|
|
||||||
See
|
|
||||||
[`groombook/org/skills/coding-standards/SKILL.md`](https://git.farh.net/groombook/org/src/branch/main/skills/coding-standards/SKILL.md)
|
|
||||||
for the engineering priority ordering, test requirements, no-hardcoded-values
|
|
||||||
rules, CalVer versioning policy, and the `git.farh.net` container registry
|
|
||||||
policy.
|
|
||||||
|
|
||||||
## Safety
|
|
||||||
|
|
||||||
See
|
|
||||||
[`groombook/org/skills/safety/SKILL.md`](https://git.farh.net/groombook/org/src/branch/main/skills/safety/SKILL.md)
|
|
||||||
for the non-negotiable rules: no plaintext secrets, no `kubectl apply` to
|
|
||||||
`groombook`, no self-merge, no direct `tofu` runs, board approval for
|
|
||||||
destructive actions, escalation protocol.
|
|
||||||
@@ -108,11 +108,6 @@ Expected: one row, `role = 'groomer'`. If zero rows return, the request hit the
|
|||||||
| TC-API-1.24 | Complete setup creates super user | POST /api/setup with business name (after TC-API-1.23) | First user becomes super user, setup completes | Setup errors, 403 on admin endpoints |
|
| TC-API-1.24 | Complete setup creates super user | POST /api/setup with business name (after TC-API-1.23) | First user becomes super user, setup completes | Setup errors, 403 on admin endpoints |
|
||||||
| TC-API-1.25 | Super user accesses admin features | After TC-API-1.24, GET /api/staff/me and verify isSuperUser: true | isSuperUser: true, admin endpoints accessible | 403 on admin, isSuperUser: false |
|
| TC-API-1.25 | Super user accesses admin features | After TC-API-1.24, GET /api/staff/me and verify isSuperUser: true | isSuperUser: true, admin endpoints accessible | 403 on admin, isSuperUser: false |
|
||||||
| TC-API-1.26 | Auto-provision skipped during OOBE | During fresh setup (needsSetup: true), complete OIDC login — verify no duplicate staff record created before setup completes | No duplicate staff, OOBE completes successfully | Duplicate staff record, 403 before setup, auto-provision interferes with OOBE |
|
| TC-API-1.26 | Auto-provision skipped during OOBE | During fresh setup (needsSetup: true), complete OIDC login — verify no duplicate staff record created before setup completes | No duplicate staff, OOBE completes successfully | Duplicate staff record, 403 before setup, auto-provision interferes with OOBE |
|
||||||
| TC-API-1.27 | Multi-origin CORS — demo host sign-in | `POST /api/auth/sign-in/social` with `callbackURL=https://demo.groombook.dev` | 200 OK, no origin-mismatch error | 400/403 "Origin mismatch" |
|
|
||||||
| TC-API-1.28 | Multi-origin CORS — farh.net host sign-in | `POST /api/auth/sign-in/social` with `callbackURL=https://groombook.farh.net` | 200 OK, no origin-mismatch error | 400/403 "Origin mismatch" |
|
|
||||||
| TC-API-1.29 | CORS — untrusted origin blocked (GRO-2586) | POST /api/auth/sign-in/social with `Origin: https://evil.example.com` header | Response has **no** `Access-Control-Allow-Origin` header — attacker origin is not reflected | `Access-Control-Allow-Origin: https://evil.example.com` present in response |
|
|
||||||
| TC-API-1.30 | CORS — trusted origin allowed (GRO-2586) | POST /api/auth/sign-in/social with `Origin: https://uat.groombook.dev` header | `Access-Control-Allow-Origin: https://uat.groombook.dev` + `Access-Control-Allow-Credentials: true` | CORS header absent or trusted origin rejected |
|
|
||||||
| TC-API-1.31 | CORS — untrusted preflight blocked (GRO-2586) | `curl -i -X OPTIONS https://uat.groombook.dev/api/auth/sign-in/social -H 'Origin: https://evil.example.com' -H 'Access-Control-Request-Method: POST'` | Response has **no** `Access-Control-Allow-Origin: https://evil.example.com` | Preflight reflects attacker origin |
|
|
||||||
|
|
||||||
### 4.2 Client Management
|
### 4.2 Client Management
|
||||||
|
|
||||||
|
|||||||
@@ -1,60 +0,0 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
|
||||||
import { enforceAuthCors } from "../lib/auth-cors.js";
|
|
||||||
|
|
||||||
const TRUSTED = ["https://uat.groombook.dev", "https://dev.groombook.dev"];
|
|
||||||
|
|
||||||
/** Simulates Better Auth reflecting the request Origin (the pre-fix bug). */
|
|
||||||
function makeReflectedResponse(origin: string | null): Response {
|
|
||||||
return new Response('{"ok":true}', {
|
|
||||||
status: 200,
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
...(origin
|
|
||||||
? {
|
|
||||||
"Access-Control-Allow-Origin": origin,
|
|
||||||
"Access-Control-Allow-Credentials": "true",
|
|
||||||
}
|
|
||||||
: {}),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("enforceAuthCors (GRO-2586)", () => {
|
|
||||||
it("passes trusted origin through with credentials", () => {
|
|
||||||
const origin = "https://uat.groombook.dev";
|
|
||||||
const res = enforceAuthCors(origin, TRUSTED, makeReflectedResponse(origin));
|
|
||||||
expect(res.headers.get("Access-Control-Allow-Origin")).toBe(origin);
|
|
||||||
expect(res.headers.get("Access-Control-Allow-Credentials")).toBe("true");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("strips ACAO for attacker origin (credentialed cross-origin read blocked)", () => {
|
|
||||||
const origin = "https://evil.example.com";
|
|
||||||
const res = enforceAuthCors(origin, TRUSTED, makeReflectedResponse(origin));
|
|
||||||
expect(res.headers.get("Access-Control-Allow-Origin")).toBeNull();
|
|
||||||
expect(res.headers.get("Access-Control-Allow-Credentials")).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("strips ACAO when no Origin header (undefined)", () => {
|
|
||||||
const res = enforceAuthCors(undefined, TRUSTED, makeReflectedResponse(null));
|
|
||||||
expect(res.headers.get("Access-Control-Allow-Origin")).toBeNull();
|
|
||||||
expect(res.headers.get("Access-Control-Allow-Credentials")).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("preserves non-CORS response headers and status from Better Auth", () => {
|
|
||||||
const origin = "https://evil.example.com";
|
|
||||||
const res = enforceAuthCors(origin, TRUSTED, makeReflectedResponse(origin));
|
|
||||||
expect(res.headers.get("Content-Type")).toBe("application/json");
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("second trusted origin is also allowed", () => {
|
|
||||||
const origin = "https://dev.groombook.dev";
|
|
||||||
const res = enforceAuthCors(origin, TRUSTED, makeReflectedResponse(origin));
|
|
||||||
expect(res.headers.get("Access-Control-Allow-Origin")).toBe(origin);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("empty string origin is treated as untrusted", () => {
|
|
||||||
const res = enforceAuthCors("", TRUSTED, makeReflectedResponse(""));
|
|
||||||
expect(res.headers.get("Access-Control-Allow-Origin")).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
+2
-4
@@ -3,7 +3,6 @@ import { Hono } from "hono";
|
|||||||
import { logger } from "hono/logger";
|
import { logger } from "hono/logger";
|
||||||
import { cors } from "hono/cors";
|
import { cors } from "hono/cors";
|
||||||
import { getAuth, initAuth, getActiveProviders } from "./lib/auth.js";
|
import { getAuth, initAuth, getActiveProviders } from "./lib/auth.js";
|
||||||
import { enforceAuthCors } from "./lib/auth-cors.js";
|
|
||||||
import { clientsRouter } from "./routes/clients.js";
|
import { clientsRouter } from "./routes/clients.js";
|
||||||
import { petsRouter } from "./routes/pets.js";
|
import { petsRouter } from "./routes/pets.js";
|
||||||
import { servicesRouter } from "./routes/services.js";
|
import { servicesRouter } from "./routes/services.js";
|
||||||
@@ -201,10 +200,9 @@ api.use("*", resolveStaffMiddleware);
|
|||||||
// Better-Auth handler — mounted as sub-app to handle all /api/auth/* routes
|
// Better-Auth handler — mounted as sub-app to handle all /api/auth/* routes
|
||||||
// authMiddleware and resolveStaffMiddleware both skip /api/auth/ paths
|
// authMiddleware and resolveStaffMiddleware both skip /api/auth/ paths
|
||||||
const authRouter = new Hono();
|
const authRouter = new Hono();
|
||||||
authRouter.all("/*", async (c) => {
|
authRouter.all("/*", (c) => {
|
||||||
try {
|
try {
|
||||||
const res = await getAuth().handler(c.req.raw);
|
return getAuth().handler(c.req.raw);
|
||||||
return enforceAuthCors(c.req.header("origin"), TRUSTED_ORIGINS, res);
|
|
||||||
} catch {
|
} catch {
|
||||||
return c.json({ error: "Authentication not configured" }, 503);
|
return c.json({ error: "Authentication not configured" }, 503);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,22 +0,0 @@
|
|||||||
/**
|
|
||||||
* Enforces the trusted-origins CORS allowlist on a raw Response from Better Auth.
|
|
||||||
* Better Auth reflects the request Origin into Access-Control-Allow-Origin
|
|
||||||
* regardless of the trustedOrigins config, allowing credentialed cross-origin reads
|
|
||||||
* from arbitrary attacker origins. This wrapper strips CORS headers for any origin
|
|
||||||
* not in the allowlist. (GRO-2586)
|
|
||||||
*/
|
|
||||||
export function enforceAuthCors(
|
|
||||||
requestOrigin: string | undefined,
|
|
||||||
trustedOrigins: string[],
|
|
||||||
res: Response
|
|
||||||
): Response {
|
|
||||||
const headers = new Headers(res.headers);
|
|
||||||
if (requestOrigin && trustedOrigins.includes(requestOrigin)) {
|
|
||||||
headers.set("Access-Control-Allow-Origin", requestOrigin);
|
|
||||||
headers.set("Access-Control-Allow-Credentials", "true");
|
|
||||||
} else {
|
|
||||||
headers.delete("Access-Control-Allow-Origin");
|
|
||||||
headers.delete("Access-Control-Allow-Credentials");
|
|
||||||
}
|
|
||||||
return new Response(res.body, { status: res.status, statusText: res.statusText, headers });
|
|
||||||
}
|
|
||||||
+2
-4
@@ -118,8 +118,7 @@ export async function initAuth(): Promise<void> {
|
|||||||
updateAge: 60 * 60 * 24,
|
updateAge: 60 * 60 * 24,
|
||||||
cookieCache: { enabled: false },
|
cookieCache: { enabled: false },
|
||||||
},
|
},
|
||||||
trustedOrigins: (process.env.CORS_ORIGIN ?? "http://localhost:5173")
|
trustedOrigins: [process.env.CORS_ORIGIN ?? "http://localhost:5173"],
|
||||||
.split(",").map((s) => s.trim()).filter(Boolean),
|
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -309,8 +308,7 @@ export async function initAuth(): Promise<void> {
|
|||||||
maxAge: 5 * 60, // 5 minutes
|
maxAge: 5 * 60, // 5 minutes
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
trustedOrigins: (process.env.CORS_ORIGIN ?? "http://localhost:5173")
|
trustedOrigins: [process.env.CORS_ORIGIN ?? "http://localhost:5173"],
|
||||||
.split(",").map((s) => s.trim()).filter(Boolean),
|
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user