Skip to content

Commit c36b050

Browse files
authored
Feature/team access audit log (#733)
* feat: add team access management (admin/developer roles) Adds /dashboard/settings/team and /api/team (GET/POST/DELETE), backed by a mock store in dev and proxied to mux-backend when NEXT_PUBLIC_API_URL is configured. Admins can add/remove members; developers get a read-only view. No invite-email flow yet — members are added directly, matching current scope. * fix: never serve mock activity feed in production /api/activity fell back to the mock-transaction heuristic unconditionally, unlike /api/wallets and friends, so a production deploy with no NEXT_PUBLIC_API_URL configured would silently serve fabricated activity instead of a 503. Adds the same isMockFallbackAllowed() gate used elsewhere, and records mock activity into a small append-only in-memory store (src/lib/audit/log.ts) as a placeholder shape for the real backend-owned immutable audit log.
1 parent 7bc7095 commit c36b050

11 files changed

Lines changed: 728 additions & 8 deletions

File tree

README.md

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -210,5 +210,10 @@ stay easy to find and don't clutter the repo root as features evolve.
210210

211211
* Per-key usage analytics
212212
* Webhooks and notifications for SDK events
213-
* Team access management
214-
* Audit logs for all wallet and API activity
213+
* ~~Team access management~~ — basic admin/developer member management is in
214+
at `/dashboard/settings/team` (`/api/team`); see
215+
[`docs/team-access-and-audit-log.md`](docs/team-access-and-audit-log.md)
216+
* ~~Audit logs for all wallet and API activity~~`/api/activity` now
217+
follows the same production/mock split as the rest of the app instead of
218+
always serving mock data; see
219+
[`docs/team-access-and-audit-log.md`](docs/team-access-and-audit-log.md)

docs/team-access-and-audit-log.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
# Team access management & activity audit log
2+
3+
## Team access
4+
5+
`GET /api/team` and `POST /api/team` (and `DELETE /api/team/[id]`) manage the
6+
list of people with access to this project's dashboard. Each member has a
7+
`role` of `"admin"` or `"developer"` — the login role already returned by
8+
`AuthContext`'s `AuthUser.role`. Only `admin` can add or remove members;
9+
`developer` gets a read-only view. There is no email-invite flow: an admin
10+
adds a member directly by name/email/role.
11+
12+
The management UI lives at `/dashboard/settings/team`.
13+
14+
Like the other API routes in this app, `/api/team` proxies to the configured
15+
backend (`NEXT_PUBLIC_API_URL` or legacy aliases) when set, falls back to an
16+
in-repo mock store (`src/mock-data/team.ts`) for local dev/CI when no backend
17+
is configured, and returns `503 backend_unavailable` instead of mock data
18+
when running with `NODE_ENV=production` and no backend configured — see
19+
`isMockFallbackAllowed()` in `src/lib/api/config.ts`.
20+
21+
## Activity / audit log
22+
23+
`GET /api/activity` previously fell back to a mock-transaction heuristic
24+
regardless of `NODE_ENV`, unlike `/api/wallets` and friends. It now follows
25+
the same production gate as the rest of the app: with a backend configured,
26+
it proxies to the backend's real event/activity feed; with no backend and
27+
`NODE_ENV=production`, it returns `503 backend_unavailable` instead of mock
28+
data; only outside production does it fall back to mock data, which it now
29+
also appends to an in-memory append-only store (`src/lib/audit/log.ts`) as a
30+
placeholder shape for the real immutable audit log the backend should serve.
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
/**
2+
* Regression coverage for the production/mock split on GET /api/activity.
3+
*
4+
* Before this test, the mock activity fallback ran unconditionally
5+
* regardless of NODE_ENV, so a production deployment with no
6+
* NEXT_PUBLIC_API_URL configured would silently serve fabricated activity
7+
* items instead of failing loudly. See src/lib/api/runtimeMode.ts /
8+
* isMockFallbackAllowed() for the pattern this mirrors from
9+
* src/app/api/wallets/route.ts.
10+
*/
11+
12+
import { afterEach, describe, expect, it, vi } from "vitest";
13+
import { GET } from "./route";
14+
15+
describe("GET /api/activity in production without a configured backend", () => {
16+
afterEach(() => {
17+
vi.unstubAllEnvs();
18+
});
19+
20+
it("returns 503 instead of falling back to mock activity data", async () => {
21+
vi.stubEnv("NEXT_PUBLIC_API_URL", "");
22+
vi.stubEnv("NEXT_PUBLIC_MUX_API_URL", "");
23+
vi.stubEnv("NEXT_PUBLIC_API_BASE", "");
24+
vi.stubEnv("NODE_ENV", "production");
25+
26+
const res = await GET();
27+
expect(res.status).toBe(503);
28+
const body = await res.json();
29+
expect(body.error).toBe("backend_unavailable");
30+
});
31+
});

src/app/api/activity/route.ts

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { NextResponse } from "next/server";
2-
import { getApiBaseUrl, getUpstreamAuthHeaders } from "@/lib/api/config";
2+
import { getApiBaseUrl, getUpstreamAuthHeaders, isMockFallbackAllowed } from "@/lib/api/config";
3+
import { appendAuditLog, getAuditLog } from "@/lib/audit/log";
34
import { mockTransactions } from "@/mock-data/transactions";
45
import type { Transaction } from "@/types/transaction";
56

@@ -72,10 +73,26 @@ export async function GET() {
7273
}
7374
}
7475

75-
// --- Mock fallback (no NEXT_PUBLIC_API_URL set) ---
76-
// Synthesizes activity items from mock transaction data; used for local
77-
// dev / CI only when no backend is configured.
78-
const activities = mockTransactions.map(mapTransactionToActivity);
76+
if (!isMockFallbackAllowed()) {
77+
return NextResponse.json(
78+
{
79+
error: "backend_unavailable",
80+
message:
81+
"No activity backend is configured for this production deployment. Set NEXT_PUBLIC_API_URL.",
82+
},
83+
{ status: 503 },
84+
);
85+
}
86+
87+
// --- Mock fallback (no NEXT_PUBLIC_API_URL set, non-production only) ---
88+
// Synthesizes activity items from mock transaction data and records them
89+
// into the in-memory audit log; used for local dev / CI only when no
90+
// backend is configured. See src/lib/audit/log.ts.
91+
if (getAuditLog().length === 0) {
92+
for (const activity of mockTransactions.map(mapTransactionToActivity)) {
93+
appendAuditLog(activity);
94+
}
95+
}
7996

80-
return NextResponse.json({ data: activities });
97+
return NextResponse.json({ data: getAuditLog() });
8198
}

src/app/api/team/[id]/route.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { NextResponse } from "next/server";
2+
import {
3+
getApiBaseUrl,
4+
getUpstreamAuthHeaders,
5+
isMockFallbackAllowed,
6+
} from "@/lib/api/config";
7+
import { removeTeamMember } from "@/mock-data/team";
8+
9+
/**
10+
* DELETE /api/team/[id]
11+
*
12+
* Removes a team member. Proxies to the backend when configured; otherwise
13+
* falls back to the in-repo mock store outside production only.
14+
*/
15+
export async function DELETE(
16+
_request: Request,
17+
{ params }: { params: Promise<{ id: string }> },
18+
) {
19+
const { id } = await params;
20+
const backendUrl = getApiBaseUrl();
21+
22+
if (backendUrl) {
23+
try {
24+
const upstream = await fetch(`${backendUrl}/team/${id}`, {
25+
method: "DELETE",
26+
headers: {
27+
"content-type": "application/json",
28+
...getUpstreamAuthHeaders(),
29+
},
30+
});
31+
32+
if (!upstream.ok) {
33+
return NextResponse.json(
34+
{ error: "Unable to remove team member on the backend" },
35+
{ status: upstream.status || 502 },
36+
);
37+
}
38+
39+
return NextResponse.json({ ok: true });
40+
} catch {
41+
return NextResponse.json(
42+
{ error: "Unable to reach the team backend" },
43+
{ status: 502 },
44+
);
45+
}
46+
}
47+
48+
if (!isMockFallbackAllowed()) {
49+
return NextResponse.json(
50+
{
51+
error: "backend_unavailable",
52+
message:
53+
"No team backend is configured for this production deployment. Set NEXT_PUBLIC_API_URL.",
54+
},
55+
{ status: 503 },
56+
);
57+
}
58+
59+
// --- Mock fallback (no NEXT_PUBLIC_API_URL set, non-production only) ---
60+
const removed = removeTeamMember(id);
61+
if (!removed) {
62+
return NextResponse.json({ error: "Member not found" }, { status: 404 });
63+
}
64+
return NextResponse.json({ ok: true });
65+
}

src/app/api/team/route.test.ts

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
/**
2+
* Tests for GET/POST /api/team.
3+
*
4+
* Covers:
5+
* - Mock fallback list/add in non-production when no backend is configured
6+
* - Proxies to the backend when NEXT_PUBLIC_API_URL is set
7+
* - Never falls back to mock team data in a production build — fails
8+
* loudly with 503 instead, matching the pattern in
9+
* src/app/api/wallets/route.ts / isMockFallbackAllowed().
10+
*/
11+
12+
import { afterEach, describe, expect, it, vi } from "vitest";
13+
import { GET, POST } from "./route";
14+
15+
function postRequest(body: unknown): Request {
16+
return new Request("http://localhost/api/team", {
17+
method: "POST",
18+
headers: { "content-type": "application/json" },
19+
body: JSON.stringify(body),
20+
});
21+
}
22+
23+
describe("/api/team", () => {
24+
afterEach(() => {
25+
vi.unstubAllEnvs();
26+
vi.unstubAllGlobals();
27+
});
28+
29+
describe("mock fallback (no NEXT_PUBLIC_API_URL, non-production)", () => {
30+
it("lists mock team members", async () => {
31+
vi.stubEnv("NEXT_PUBLIC_API_URL", "");
32+
vi.stubEnv("NODE_ENV", "test");
33+
34+
const res = await GET();
35+
expect(res.status).toBe(200);
36+
const body = await res.json();
37+
expect(Array.isArray(body.data)).toBe(true);
38+
expect(body.data.length).toBeGreaterThan(0);
39+
});
40+
41+
it("rejects an invalid role", async () => {
42+
vi.stubEnv("NEXT_PUBLIC_API_URL", "");
43+
vi.stubEnv("NODE_ENV", "test");
44+
45+
const res = await POST(
46+
postRequest({ name: "Test", email: "t@example.com", role: "owner" }),
47+
);
48+
expect(res.status).toBe(400);
49+
});
50+
});
51+
52+
describe("backend proxy (NEXT_PUBLIC_API_URL is set)", () => {
53+
it("proxies GET to the backend", async () => {
54+
vi.stubEnv("NEXT_PUBLIC_API_URL", "https://api.example.com");
55+
const fetchMock = vi.fn().mockResolvedValue({
56+
ok: true,
57+
json: () => Promise.resolve([{ id: "m1", role: "admin" }]),
58+
});
59+
vi.stubGlobal("fetch", fetchMock);
60+
61+
const res = await GET();
62+
expect(res.status).toBe(200);
63+
expect(fetchMock).toHaveBeenCalledWith(
64+
"https://api.example.com/team",
65+
expect.any(Object),
66+
);
67+
});
68+
});
69+
70+
describe("production without a configured backend", () => {
71+
it("returns 503 instead of silently serving mock team members", async () => {
72+
vi.stubEnv("NEXT_PUBLIC_API_URL", "");
73+
vi.stubEnv("NEXT_PUBLIC_MUX_API_URL", "");
74+
vi.stubEnv("NEXT_PUBLIC_API_BASE", "");
75+
vi.stubEnv("NODE_ENV", "production");
76+
77+
const res = await GET();
78+
expect(res.status).toBe(503);
79+
const body = await res.json();
80+
expect(body.error).toBe("backend_unavailable");
81+
});
82+
});
83+
});

0 commit comments

Comments
 (0)