-
Notifications
You must be signed in to change notification settings - Fork 0
API Reference
shipyard exposes a small HTTP surface under the Next.js App Router in src/app. There are three public auth routes and three protected routes. This page documents each: method, path, body, response, status codes, the permission it asserts, and the rate-limit group it uses.
Every protected route is wrapped by withGuard in src/lib/http.ts, which resolves the request context, applies the rate limit, asserts the permission and maps errors to status codes. A handler therefore only ever runs once the caller is authenticated, scoped to a tenant, within budget and authorised. See Architecture for the lifecycle.
- Bodies are JSON. Responses are JSON.
- The session is an httpOnly cookie named
shipyard_session. The auth routes set it; protected routes read it. You do not pass it in a header. - Errors are
{ "error": "<message>" }with the status set byerrorResponse.
| Status | Meaning | Source |
|---|---|---|
| 400 | malformed request, or an unclassified error | route validation / fallback in errorResponse
|
| 401 | no valid session | AuthError |
| 402 | usage budget exceeded | UsageLimitError |
| 403 | authenticated but not allowed (no membership, or missing permission) |
TenantResolutionError, ForbiddenError
|
| 429 | rate limited | the token-bucket limiter |
These are public (no session required) and limited by IP, because there is no tenant yet. Each constructs its own RateLimiter(RATE_LIMITS.auth).
Registers a user, creates their first organisation (owned by them), opens a session and sets the cookie.
Request:
{ "email": "owner@acme.test", "password": "password-acme-123", "organisationName": "Acme" }Response 200:
{ "userId": "<uuid>", "organisationId": "<uuid>" }Errors: 400 if any field is missing; 400 email already registered if the email exists; 429 if the IP exceeded the auth bucket. The handler is src/app/api/auth/signup/route.ts; the logic is AuthService.signup.
Verifies credentials and opens a session on the user's first organisation.
Request:
{ "email": "owner@acme.test", "password": "password-acme-123" }Response 200: { "userId": "<uuid>", "organisationId": "<uuid>" }, with the session cookie set. Errors: 400 if fields are missing; 401 invalid credentials on a bad email or password (the same message for both, so it does not reveal which); 429 on the auth bucket. Handler: src/app/api/auth/login/route.ts.
Deletes the session row for the current cookie and clears the cookie. Always returns 200 { "ok": true }, even with no session, so logout is idempotent. Handler: src/app/api/auth/logout/route.ts.
All under /api/protected. The edge middleware short-circuits these with 401 if there is no session cookie; the route then does the authoritative check via withGuard. The rate-limit group is api unless noted.
Lists the active tenant's members. Permission: members:read (held by every role).
Response 200:
{ "members": [ { "id": "...", "organisationId": "...", "userId": "...", "role": "owner", "createdAt": 0 } ] }Invites a user to the active tenant by email and role, creating the user if they do not exist. Permission: members:invite (owner and admin only).
Request: { "email": "new@acme.test", "role": "member" }. Response 200: { "membership": { ... } }. Errors: 400 if email or role is missing; 403 if the role lacks members:invite. The invite is idempotent: inviting an existing member returns the existing membership. Handler: src/app/api/protected/members/route.ts; logic: MembersService.invite.
Returns the tenant's subscription, current-period usage and the plan catalogue. Permission: billing:read (every role).
Response 200:
{
"subscription": { "plan": "pro", "status": "active", ... } ,
"usage": { "api_calls": 1234, "seats": 0 },
"plans": [ { "id": "free", "name": "Free", "pricePerMonth": 0, "budgets": { "api_calls": 1000, "seats": 3 } }, ... ]
}Subscribes the tenant to a plan. Permission: billing:manage (owner only).
Request: { "plan": "pro" }. Response 200: { "subscription": { ... } }. Errors: 400 if plan is missing or not in the catalogue; 403 if not an owner. The provider is selected by BILLING_PROVIDER: the fake in dev and tests, the Stripe adapter when set to stripe. Handler: src/app/api/protected/billing/route.ts; logic: BillingService.subscribe. See Billing.
Returns the tenant's audit entries, newest first. Permission: audit:read (owner and admin only).
Response 200: { "entries": [ { "id", "organisationId", "actorUserId", "action", "metadata", "createdAt" } ] }. metadata is a JSON string. Handler: src/app/api/protected/audit/route.ts; logic: listAudit.
| Route | Permission | owner | admin | member | viewer |
|---|---|---|---|---|---|
| GET members | members:read |
yes | yes | yes | yes |
| POST members | members:invite |
yes | yes | no | no |
| GET billing | billing:read |
yes | yes | yes | yes |
| POST billing | billing:manage |
yes | no | no | no |
| GET audit | audit:read |
yes | yes | no | no |
The full role-to-permission table is in Auth and RBAC.
There is no shipped HTTP route for provider webhooks, because the verification and mapping live in the provider and the application logic in BillingService.applyEvent. To wire a live Stripe webhook you add a route that reads the raw body and the Stripe-Signature header, calls provider.parseWebhook(rawBody, signature), then billing.applyEvent(organisationId, event). The signature check is already real in StripeBillingProvider.parseWebhook. See Billing and Security Model.
The settings dashboard at /app/settings (src/app/app/settings/page.tsx) is a server component, not an API route. It resolves the context once with resolveContext, then reads members, subscription, permissions and the last twenty audit entries directly through the services, all scoped to the active tenant. It is the readable proof that the same context-and-scope machinery the API uses also drives the UI.
# sign up; -c saves the session cookie
curl -s -c jar.txt -X POST localhost:3000/api/auth/signup \
-H 'content-type: application/json' \
-d '{"email":"o@acme.test","password":"password-acme-123","organisationName":"Acme"}'
# use the cookie on a protected route
curl -s -b jar.txt localhost:3000/api/protected/members
# subscribe to pro (owner only)
curl -s -b jar.txt -X POST localhost:3000/api/protected/billing \
-H 'content-type: application/json' -d '{"plan":"pro"}'More end-to-end flows are in Examples and Recipes.
SarmaLinux . sarmalinux.com . shipyard on GitHub
Start here
Subsystems
Reference
Operating it
Working on it
Background
SarmaLinux . sarmalinux.com