Landlord API — Slice 1: Foundation
Slice 1 makes a Better Auth API key a valid identity for the rental API, decides per-key what it may do, and freezes a public /api/v1/rental contract over the ten handlers that already exist. It adds no new business endpoints, no new tables, and keeps Bun authoritative for key identity while Go enforces a true hourly allowance in Redis.
Summary
Landlords can already be issued Better Auth API keys, but the rental API cannot read them.
Slice 1 makes a key a valid identity, decides what each key may do, and freezes a public
/api/v1/rental contract — without adding a single new business endpoint.
The document was approved on 2026-09-19, put through a design review on 2026-09-20, and the last open product decision — rate-limit behaviour — was settled on 2026-09-21. Status is Approved, ready for implementation plan.
The five slices
The full ask spans five independent subsystems. The decomposition and the order were approved before this document was written.
| Order | Slice | Depends on | Why this position |
|---|---|---|---|
| 1 | Foundation (this spec) | — | Nothing works without key auth |
| 2 | Idempotent sync | 1 | First real external writes need dedupe |
| 3 | Media sync | 1, 2 | Photos attach to deduped properties |
| 4 | Webhooks | 1 | Needs the key model; independent of 2–3 |
| 5 | CLI adapter | 1–3 | Thin client over a settled contract |
Current state — existing code
Verified against the repository on 2026-09-19, re-verified and extended 2026-09-20. Every row below was read, not assumed.
Auth and keys
| Fact | Location |
|---|---|
apiKey plugin enabled with enableSessionForAPIKeys: true | backend/bun/apps/monolith/src/config/di-container.ts:219 |
Bun gateway turns any x-api-key into a full session, replaying it against auth.handler as a synthetic get-session | api-gateway/middleware/auth.middleware.ts:54-75 |
apikey table already has permissions, expires_at, rate_limit_*, request_count, last_request, start, prefix, enabled, metadata, config_id | packages/auth-adapter/schema.ts:220 |
| Go CLI creates a key via device flow, per device, best-effort revoking the previous one | backend/go/cmd/cli/cmd/auth/login.go:104, ADR-0010/0011/0012 |
Go auth reads only Authorization and the session cookie | internal/api/http/middleware/auth_middleware.go:25-90 |
Go falls back to a DB session read, then HTTP, and builds roles from users.role + first members row | better_auth_validator.go:221, :184 |
JWT revocation is a Redis marker compared against the token's iat | better_auth_validator.go:163-175 |
Rental routing today
module.go:407-476. One shared auth middleware guards every protected branch.| Fact | Location |
|---|---|
Owner routes gated on the landlord role, which is RequireJWTRole under another name | rental/adapter/inbound/http/require_landlord.go |
RequireJWTRole denies with {"error":"insufficient role","code":"forbidden"} and deliberately bypasses the global error handler | middleware/jwt_role.go:19-41, pinned by TestRequireJWTRole_DenialBodyContract |
A different RequirePermission already exists in the same Go package (org-scoped, single string) | middleware/better_auth_rbac.go:75 |
| Cross-landlord rows return 404, never 403, on purpose | rental/application/usecase/listing.go:102-115 |
| Handlers render errors themselves; validation writes the body before returning | listing_handler.go:149 (renderListingError) |
fields on a validation response is []string, not a list of objects | property_handler.go:329 |
Property routes at :37-50, listing routes at :34-39; the photo GET is registered only when the photos service is non-nil | property_handler.go, listing_handler.go |
| Audit context carries IP, user agent, session, trace — and nothing else | middleware/audit_context.go:12-92 |
Audit storage — three tables, one usable
audit_trails
row history only
- No actor column at all — id, event_id, table_name, operation, row_id, old_data, new_data, changed_at
- Already triggered on
rental_properties(migrator_rental.go:195-199) - Keeps its job; cannot carry attribution
audit_logs (Bun)
rejected
auth-adapter/schema.ts:170-192org_id text NOT NULL REFERENCES organization(id)- Rental is orgless (ADR-0055), so the foreign key refuses the insert
audit_log (Go)
chosen
- Created by GORM AutoMigrate —
migrator.go:187includes&leaveModels.AuditLogModel{}at:199 - Model at
leave/.../models.go:209-229 OrganizationIDisvarchar(36) not null default ''with no foreign key — an empty string is the schema's existing representation of an unscoped record
Write pattern to copy: digitalworker/adapter/outbound/persistence/postgresql/audit_logger.go:24-41.
verifyApiKey in the installed plugin
| Claim | Source line |
|---|---|
The client cannot set rateLimit fields on create | index.mjs:725 |
metadata requires enableMetadata on the config | plugin config schema |
A per-key prefix is accepted on create, generated into the key and stored | index.mjs:575, :795, :807 |
Rate limiting resets only when now - lastRequest > timeWindow; every successful call updates lastRequest | index.mjs:1573-1631 |
remaining / lastRefillAt belong to a different quota mechanism, not to this limiter | same |
Config lookup is per-config; disabled configs are skipped; configId is checked strictly | index.mjs:2105, :2129, :1638 |
A catch converts non-API database exceptions into INVALID_API_KEY, with no reason attached | index.mjs:1808-1825 |
That last row is why the verification spike exists.
Decisions
Original decisions (2026-09-19)
| # | Decision | Chosen |
|---|---|---|
| D1 | How far a key may push a listing | Full — a key may also submit |
| D2 | Which API surface a key may reach | Everything the landlord can do, each part switchable per key |
| D3 | Attribution recorded | Audit actor, listing origin, declaration source, admin badge — all four |
| D4 | Duplicate prevention | external_ref upsert, address fingerprint, cross-landlord flag, Idempotency-Key (Slice 2) |
| D5 | Photo sync | Fetch-by-URL, presigned upload, declarative gallery, content-hash dedupe (Slice 3) |
| D6 | Second surface | One capability layer, thin adapters |
| D7 | Extra scope | Key safety controls, webhooks, frozen contract, sandbox — all four |
| D8 | Ownership proof upload by key | Yes, behind its own permission, off by default |
| D9 | Key validation | Go asks Bun; Better Auth stays authoritative |
| D10 | Permission UI | Plain-word bundles, fine-grained storage |
| D11 | Identity reuse | Verify every request; revisit at Slice 3 |
Decisions added by the design review
D19 · Rate-limit semantics and owner
A true hourly allowance on fixed UTC clock-hour boundaries, enforced in Go against shared Redis. Bun keeps identity, revocation and the stored allowance. The plugin's own limiter is switched off for the landlord config only.
The product wants "1000 per hour" to mean exactly that — a boundary reset whether the caller was busy or idle.
Rejected: the plugin's idle-window limiter, where a caller in continuous traffic never resets.
A rolling window was not chosen either — this is fixed buckets, not a sliding window. Direct
apikey reads from Go were rejected as a violation of D9 and ADR-0006.
D12 · Where key auth is mounted
A separate APIKeyAuthMiddleware, mounted only on an explicit v1 route allowlist.
AuthMiddleware is untouched.
The v1 permission gate cannot be forgotten on a future route, because keys never reach any route it is not mounted on.
Rejected: a branch inside AuthMiddleware — it would let a key reach every legacy
rental route and every other module using that middleware, with no permission gate. A
deny-by-default global guard was rejected as more moving parts.
D14 · Key isolation between CLI and landlord
A dedicated landlord configId in the same plugin, with
enableSessionForAPIKeys: false for that config only. The CLI's default config
keeps today's behaviour.
Config lookup is per-config and configId is checked strictly
(index.mjs:2105, :1638), so the isolation is enforced by the plugin
rather than by our own conventions.
Rejected: accepting that every key is a full Bun account; gating every Bun route; turning the
flag off globally, which breaks grm auth login (ADR-0011).
D16 · v1 error shaping
An injected, route-selected renderer that reads domain sentinels before the handler commits output.
The stable codes do not exist on the wire today, and validation already wrote the body by the time a wrapper would see it.
Rejected: a response-shaping middleware. String-matching English messages or rewriting response bytes were rejected outright.
Remaining review decisions — D13, D15, D17, D18
| # | Chosen | Rejected |
|---|---|---|
| D13 · Credential precedence on v1 | The key is the only identity. No cookie or Authorization fallback. A raw key presented as a Bearer token or cookie does not authenticate legacy Go routes either. |
Falling back to the session when the key is missing or invalid — it downgrades identity silently. |
| D15 · Where landlord keys are managed | Rental portal, /landlord/api-keys, behind the existing LandlordRoute. |
The admin panel. ADR-0063 moved the landlord workspace out of panel; panel keeps only the admin listing review. |
| D17 · Audit destination | Existing audit_log, organization_id = '', scoped by entity_type and owner. |
A new rental audit table; a fabricated organization UUID; dropping NOT NULL on a shared column. |
| D18 · Where the declaration marker lives | rental_properties, beside the existing declaration fields. |
rental_listings has no audit trigger, so each re-stamp would destroy the prior value with no record. |
D11 consequence — real cost per request
| Step | Cost |
|---|---|
| Go → Bun verify | 1 HTTP round trip, bounded timeout, no automatic retry |
| Plugin bookkeeping | 1 write |
| User state + roles in Go | 1–2 reads |
| Redis limiter script | 1 round trip |
| Audit row, on writes only | 1 write |
Architecture
The request flow
-
Key arrives
Caller sends
x-api-key: grm_live_…to Go at/api/v1/rental/…. -
Verify against Bun
APIKeyAuthMiddleware— mounted only on the v1 allowlist — asks Bun to verify, naming the landlordconfigId. Bun callsauth.api.verifyApiKeyand returns who the key belongs to and what it may do. -
Read account state and roles
Go reads from the database: the row exists, is not banned, is not deleted, plus
users.roleand the firstmembersrow through the existingbuildRoles. -
Set the actor
User, roles, key id, key name, permissions, mode,
actor_typego on the request context. -
Charge the allowance
Go charges the request against the key's hourly allowance in Redis. Over the allowance is
429 rate_limited. -
Scope, role, handler
RequireKeyScope(resource, action)runs per route. Then the v1 landlord gate. Then the handler.
Handlers never learn a key was used. They read user_id as always. Rental is orgless
(ADR-0055), so the key path must not require or invent an organization membership;
buildRoles is reused only because it already tolerates that.
Ownership of each concern
| Concern | Owner | Why |
|---|---|---|
| Key exists, hash match | Better Auth | plugin already does it |
| Enabled, expired | Better Auth | plugin already does it |
| Allowance value per key | Better Auth | stored in the existing rate_limit_max; Bun validates at creation |
| Rate-limit enforcement and counters | Go, in Redis (new) | D19. The plugin's limiter is off for the landlord config, so there is only one limiter. |
| Which user | Better Auth | apikey.reference_id |
| Account state: banned, deleted, active | Go, from DB | new. The Redis iat denylist cannot revoke a key — a key has no iat. |
| Roles (landlord, admin) | Go, from DB | Go already builds these |
| Permission check per route | Go | new, small |
Revocation, stated plainly
| Action | Kills a session? | Kills a key? |
|---|---|---|
Redis authInvalidBefore:<userID> marker | yes | no — keys carry no iat |
| Ban / delete / deactivate the account | yes | yes, through the account-state read |
enabled = false on the key row | no | yes, that key only |
There is no "log out everywhere" for keys. The account-state read is what makes a ban effective.
Failure modes
Go does not refuse to boot without Redis, because that would take the whole API down for a v1-only dependency. It emits an explicit configuration diagnostic at startup naming v1 as degraded. Real Redis is a release-readiness requirement for v1 in development, CI and deployment.
Session callers on legacy routes are unaffected only when their JWT verifies locally
inside Go. That is the common case, not all cases: an opaque session token that falls
through to validateViaService still reaches Bun. v1 is unaffected in a different
sense — it does not serve session callers at all.
Route mounting
v1 mounts an explicit list of handler methods. It does not call the existing
RegisterRoutes for properties, because that function also registers upload and
proof routes that are out of scope for v1.
| Case | Response |
|---|---|
| Path not on the v1 allowlist | 404 route_not_found |
| Registered path, method not registered | 405 method_not_allowed |
Neither runs key handling: no verify call, no scope check, no counter update. Both answer in the v1 envelope so an integrator gets a parseable response rather than a framework default.
Bun changes
Four endpoints, one config change, one guard.
Landlord key management — session-authenticated, own keys only
POST /api/landlord/api-keys
GET /api/landlord/api-keys
DELETE /api/landlord/api-keys/:id (sets enabled = false; never a physical delete)
The server, not the client, fixes: the landlord configId, the prefix, the
metadata.mode, the expanded permission map, and referenceId = the
session user. It requires the global landlord role.
Internal verification — server-to-server
POST /api/internal/api-key/verify
x-service-secret: <shared secret>
{ "key": "grm_live_...", "config_id": "<landlord config>" }
200 { "valid": true,
"user_id": "usr_9",
"key_id": "key_abc",
"key_name": "propertyguru-sync",
"permissions": { "listings": ["read","write"], "properties": ["read","write"] },
"metadata": { "mode": "live" },
"rate_limit": { "limit": 1000, "window_ms": 3600000 } }
401 { "valid": false, "code": "invalid_api_key" | "key_disabled" | "key_expired" }
429 { "valid": false, "code": "rate_limited", "retry_after": 42 }
503 { "valid": false, "code": "auth_unavailable" }
The endpoint names the landlord configId explicitly and checks it. Keys from the
default CLI config are therefore invalid on v1 — validateApiKey
(index.mjs:1638) checks configId strictly.
Config change
A second apiKey config for landlords, with enableSessionForAPIKeys: false,
enableMetadata: true, and its rate limiter disabled, so only Go's
limiter can reject a request. Nothing changes for the default CLI config.
Guard on the public plugin routes
/api/auth/api-key/* still exists and the CLI still needs create (ADR-0011).
| Public route | Guard |
|---|---|
create | reject the landlord configId; reject a landlord prefix. Default CLI config only. |
update | reject entirely for landlord-config keys — this is the mode and metadata mutation path |
delete | reject landlord-config keys outright, not merely keys owned by someone else. Only the dedicated management endpoint revokes a landlord key, and it soft-disables with enabled = false. Default-config keys: own keys only. |
get / list | own keys only; the key and its hash are redacted, start and prefix only |
Go changes
BetterAuthValidator.ValidateAPIKey(ctx, key)— the HTTP call above, beside the existingvalidateViaHTTP.APIKeyAuthMiddleware— new, mounted only on the v1 allowlist.AuthMiddlewareis not modified.RequireKeyScope(resource, action)— new middleware. Named this way becauseRequirePermissionis already taken in the same package (better_auth_rbac.go:75) with different semantics.- A v1 landlord gate returning the v1 envelope.
RequireJWTRoleand its pinned denial contract stay exactly as they are. - A v1 error renderer, injected per route group.
- An audit writer on
port.Repos. - The hourly limiter and its Lua script, plus startup validation of the Redis deployment constraint.
Permissions
Two actions: read, write. A delete is a write. The landlord ticks
plain-language bundles; we store the expanded map. Bun owns the expansion table and is the only
writer. Go consumes the expanded map as returned by the verify endpoint and never re-derives a
bundle.
| Landlord sees | Stored as | Default | Slice 1 state |
|---|---|---|---|
| Manage my listings | properties:[read,write], listings:[read,write] | on | active |
| Upload photos | media:[read,write] | on | reads only — GET /properties/:id/photos. Upload arrives in Slice 3. |
| Upload ownership documents | proofs:[read,write] | off | unavailable until Slice 3 — no endpoint of any kind |
| Handle enquiries & offers | enquiries:[read,write] | off | no endpoint yet — disabled in the form |
| Money, agreements, tenants | agreements, tenancies, payments, handover, contacts, each [read,write] | off | no endpoint yet — disabled in the form |
The full vocabulary is stored from Slice 1 so that keys issued now keep working unchanged as later slices open endpoints. The form shows every bundle; bundles with no reach are rendered disabled with the accurate reason — not silently removed, and not labelled "read only" when no read exists either.
The gate
v1 is key-only (D13). There are no session callers on it.
| Caller | Result |
|---|---|
| No key, or empty / invalid key | 401 invalid_api_key |
| Key holding the permission | passes |
| Key without the permission | 403 insufficient_permission |
A cookie or Authorization header on a v1 route is ignored. It cannot raise or lower
the caller's authority. Legacy /api/rental/... is unchanged: session callers only,
no key handling, no permission gate. The two surfaces do not share an identity path.
Rate limiting — settled 2026-09-21
The decision
A true hourly allowance, configurable per key, default 1000 per hour. The hour is a fixed UTC clock hour. It resets on the boundary whether the caller was busy or idle. There is no inactivity requirement. This is deliberately not a rolling or sliding window, and the document must not be read as promising one.
Why the plugin's limiter could not be used
index.mjs:1573-1631: the counter resets only when
now - lastRequest > timeWindow, and every successful call updates
lastRequest. A caller in continuous traffic therefore never resets — an idle-window
limiter, not an hourly one. remaining and lastRefillAt belong to a
separate quota mechanism and do not describe it.
The pre-review draft derived its headers from rate_limit_max, remaining
and last_refill_at. That mapping was wrong at the source and is withdrawn. The
plugin's limiter is switched off for the landlord config so two limiters can never both reject a
request; the CLI config keeps it.
Where each part lives
- Key identity, revocation, expiry
- Bun / Better Auth (unchanged, D9)
- Allowance value, validated at creation
- Bun, stored in
rate_limit_max - Window length
- fixed at 3,600,000 ms; not per-key
- Counting and rejection
- Go, against shared Redis
- Published headers
- Go, from the limiter's own result
The counter
One Redis key per verified API key ID — never the raw key, never a hash of it. The value holds the bucket number and the count for that bucket.
-
Read the clock from Redis
The script reads the current time from Redis
TIME. Redis is the single clock, so instances with skewed host clocks cannot split one hour into two buckets. -
Derive the bucket
The UTC clock-hour bucket number comes from that time.
-
Reset a stale bucket
If the stored bucket number is older, the count resets to zero.
-
Check before counting
If the count has already reached the allowance, the script returns "rejected" and leaves the count unchanged.
-
Otherwise increment
Increment, and set the expiry to the next boundary.
-
Return the verdict
Admitted or rejected, the count, the allowance and the reset timestamp.
One declared key per call. No dynamic access to undeclared keys, so the script stays correct under Redis Cluster.
Headers
Always sent on a v1 response produced by the limiter, including the 429. Derived from the atomic result, never from plugin columns.
X-RateLimit-Limit: 1000
X-RateLimit-Remaining: 847
X-RateLimit-Reset: 1789812000 (next UTC hour boundary, unix seconds)
Retry-After: 42 (429 only; whole seconds to that boundary)
When authentication fails, or Redis is unavailable, no counters exist — so no counter headers are sent. Nothing is invented.
Known behaviour, stated rather than hidden
Redis deployment constraint
The counter is live state. If it is lost, allowances silently reset.
| Requirement | Checked |
|---|---|
The limiter keyspace is noeviction — counters are never evicted under memory pressure | startup validation; refuse to serve v1 and log explicitly if not |
| Redis persistence is enabled, so a restart does not drop live buckets | startup validation |
| Restart recovery behaves as specified | integration test |
If the deployment cannot be validated, v1 answers 503 rate_limit_unavailable rather
than counting on state it cannot trust.
Attribution and audit
D3 selected all four items. Slice 1 delivers the two that make D1 honest. The other two are product surface and stay in Slice 2, stated as such.
| Item | Slice | Detail |
|---|---|---|
| Audit records actor and key | 1 | one row per successful v1 write |
| Declaration records how it was signed | 1 | new columns on rental_properties |
| Listing stores its origin | 2 | source (panel | api | cli), key name |
| Admin review shows an API badge | 2 | moderation queue marks API-submitted listings |
Declaration source
New nullable columns on rental_properties, beside the existing
declaration fields: declaration_source, declaration_api_key_id, and the
key name snapshot.
Nullable, because historical declarations have no source and none may be invented for them. Submit and any review-forcing edit or resubmission stamp these fields inside the same write, so a stamp and its row change cannot diverge.
This is an engineering attribution requirement. It is not a legal claim, and no legal opinion is relied on. ADR-0059 concerns agreement signatures, ADR-0057 listing slugs, ADR-0058 agreement documents — none of them governs the ownership declaration.
Key-write audit
Every successful v1 write records one row in the existing audit_log: actor user,
actor_type, key id, key name, action, entity type, entity id, trace id, in
details.
organization_id is written as '', which is the column's existing default
and the schema's representation of an unscoped record (models.go:209-229). No
organization row is created, referenced or implied. Rental audit queries scope by
entity_type and owner, never by organization.
audit_trails keeps doing what it does — row snapshots with no actor. The two systems
are distinct (ADR-0007) and neither replaces the other.
Key management
No new tables. Every column already exists on apikey.
The page lives in the rental portal at /landlord/api-keys, behind the existing
LandlordRoute (frontend/solidstart/apps/rental/src/routes/landlord/LandlordRoute.tsx).
Not the admin panel: ADR-0063 moved the landlord workspace out of panel, and a panel deploy must
not be able to break a paying rental customer. It calls the Bun endpoints with the shared core
auth client, and adds no Go CRUD.
Create form
| Field | Column | Default | Set by |
|---|---|---|---|
| Name | name | — | landlord |
| Permissions | permissions | listings + photos | landlord, from the bundle whitelist |
| Expiry | expires_at | 90 days | landlord |
| Hourly allowance | rate_limit_max | 1000 | landlord within bounds, server validates and writes |
| Window | rate_limit_time_window | fixed 3,600,000 ms | server, not offered to the landlord |
| Mode | metadata.mode | live | landlord chooses, server writes |
| Prefix | prefix | grm_live_ / grm_test_ | server, from the mode |
| Config | config_id | landlord config | server |
The allowance stays landlord-configurable, as the original create form intended. The installed
plugin refuses client-set rateLimit fields (index.mjs:725), so the value
travels as a request field to the Bun management endpoint, which validates it against bounds and
writes it.
| Bound | Value | Nature |
|---|---|---|
| Default | 1000 per hour | product default |
| Minimum | 1 | hard floor |
| Maximum | 10000 | provisional deployment ceiling, server-configurable |
The maximum is a deployment setting, not a published tier. Nothing in the public contract promises it, and changing it is a configuration change rather than a contract change. The window is not configurable — it is one UTC clock hour, always.
Mode is immutable after issue. The prefix and metadata.mode must agree; the server
validates that pair. A key whose mode is absent or malformed fails closed — it does not default to
live. A key with empty or missing permissions is denied on every v1 route. No component writes the
apikey table directly.
Reveal rule and list view
The full key is shown once, at creation. Never again. The list view shows
start only, for example grm_live_8f3a…; get and list responses redact the
key and its hash.
The list shows name, permissions, hourly allowance, last_request rendered as "last
used", expiry, mode, and a revoke button. Revoke sets enabled = false.
last_request stays the source for "last used" even though the plugin's limiter is off
for this config — the plugin still stamps it. A regression test pins that, so an upgrade that
changes it fails loudly instead of silently emptying the column. Nothing derives "last used" from
Redis or from the audit log.
Test mode
Test keys carry the prefix grm_test_. A key's prefix cannot change after issue, so the
prefix is reserved now rather than in Slice 4. Row marking (is_test on listings and
properties) is Slice 2 work, so in Slice 1 a test key reads normally and is
blocked on every write with 403 test_mode_unavailable.
The form labels the mode control accordingly: a test key is read-only until Slice 2. The control stays, because a sandbox was part of the approved D7 scope and removing it would be a product change. Slice 2 lifts the block and marks the rows — avoiding the trap where a test key silently writes real listings into the admin review queue.
The frozen public contract
Go currently has three error shapes: the global handler's {success, message, code}
where code is HTTP status text; RequireJWTRole's {error, code}
which deliberately bypasses that handler; and validation responses that add a fields
list of strings. Repo-wide normalisation was attempted before and reverted. This design does not
repeat that.
/api/rental/... portal, sessions unchanged forever
/api/v1/rental/... public, keys frozen contract
Same handlers underneath, different renderer. There is no forked route tree.
Error envelope
{
"error": {
"code": "validation_failed",
"message": "validation failed",
"fields": [ "monthly_rent" ]
},
"request_id": "trace_9f2a..."
}
fields appears only on validation failures, and it is a list of field
names — the existing []string shape (property_handler.go:329). That is
settled, not deferred. Per-field reasons are not published, because the validation layer does not
produce typed reason data today. A non-validation error carries no fields key at all.
The renderer is injected and selected per route group, and runs before the handler
commits output. It maps domain sentinels to codes; it never matches English message strings and
never rewrites response bytes. It covers bind and validation failures, early auth failures, 404 and
405, and a sanitised 500 internal_error that leaks nothing. The trace id is created
before authentication, so a request that fails at the key check still carries a
request_id a landlord can quote. Legacy responses are preserved by regression goldens.
code is a stable snake_case string, never HTTP status text. Once published it cannot
change.
| Status | Codes |
|---|---|
| 400 | validation_failed, declaration_not_accepted, no_ownership_proof |
| 401 | invalid_api_key, key_expired, key_disabled |
| 403 | insufficient_permission, insufficient_role, test_mode_unavailable, account_unavailable |
| 404 | property_not_found, listing_not_found, route_not_found |
| 405 | method_not_allowed |
| 409 | listing_status_conflict, active_listing_exists |
| 429 | rate_limited |
| 500 | internal_error |
| 503 | auth_unavailable, media_unavailable, rate_limit_unavailable |
Success bodies
Rental entities are already consistently snake_case (property_id,
monthly_rent, deposit_months) and are reused rather than duplicated into
parallel public DTOs. The risk is that an internal field rename silently breaks an outsider's
script. The repo's existing nric_masking_golden_test.go and
contact_reveal_golden_test.go pin response JSON only; the v1 harness is table-driven
and pins method, route, request body, query, status, response body and error codes,
and the same table is asserted against the OpenAPI document.
Endpoints in v1 for Slice 1
GET /api/v1/rental/properties
POST /api/v1/rental/properties
GET /api/v1/rental/properties/:id
PUT /api/v1/rental/properties/:id
GET /api/v1/rental/properties/:id/photos
GET /api/v1/rental/listings/:id
POST /api/v1/rental/listings
PUT /api/v1/rental/listings/:id
POST /api/v1/rental/listings/:id/submit
POST /api/v1/rental/listings/:id/withdraw
All ten exist today (property_handler.go:37-50, listing_handler.go:34-39)
and are mounted by explicit selection.
The OpenAPI document is hand-written at backend/go/openapi/rental-v1.yaml. The Bun
generator is a stub and Go has none, so generating one is its own project. Slice 1's surface is
small enough to write by hand, and the contract table is asserted against it, so the document
cannot drift silently.
Internal endpoint isolation
fly.toml exposes one public http_service on port 3100. There is no
evidence of any path-deny capability at that edge, so a path rule is not relied on.
| Layer | Rule |
|---|---|
| Listener | The internal handler is mounted on its own listener and port, not listed in the public service config. Reachable over private networking; localhost in development. |
| Public port | Returns 404 for the internal route even when a valid secret is presented. |
| Secret | Mandatory. Constant-time compare. Boot refuses to start when it is unset or too short. No development default. |
| Go side | A separately configured internal base URL, not the public Bun URL. |
| Logging | The raw key and the secret are never logged, at any level. |
One secret is sufficient for Slice 1. Rotation is a coordinated restart and is documented as such; two-secret machinery is not built on speculation. Private connectivity between the two runtimes is a release acceptance check, not an assumption — it is not claimed to be provisioned already.
Testing
Full test matrix — 30 rows
| What | Test kind | Fails when |
|---|---|---|
| Bundle to permission map | unit, table-driven | a bundle expands to the wrong resources |
RequireKeyScope | unit | a key without the permission passes |
| v1 rejects session credentials | unit | a cookie, Bearer token, or raw key sent as Bearer authenticates a v1 route |
| Legacy rejects keys | unit | x-api-key gains any authority on /api/rental/... |
| Unlisted v1 path | unit | it is handled at all, or answers with anything but 404 route_not_found in the v1 envelope |
| Unregistered method on a registered path | unit | it answers with anything but 405 method_not_allowed in the v1 envelope |
| Neither runs key handling | unit | a verify call, scope check or counter update happens on a 404 or 405 |
ValidateAPIKey error mapping | unit | Bun being down returns 401 instead of 503 |
| Account state | integration | a banned, deleted, missing or inactive owner's key still works, or is refused with anything but 403 account_unavailable |
| Cross-config rejection | integration | a default CLI key authenticates on v1 |
| Privilege escalation | integration | a CLI key mints a landlord key through the management endpoints |
| Public plugin guard | integration | update mutates a landlord key's mode or metadata; get/list reveal a key or hash |
| Key lifecycle | integration | a revoked or expired key still works |
| Test-key write block | integration | a grm_test_ key writes a real row |
| Audit row on write | integration | a successful v1 write leaves no audit_log row, or writes a non-empty organization_id |
| Audit rollback | integration | a failed business write leaves an audit row behind, or a successful one leaves none |
| Declaration snapshot | integration | a re-stamp loses the previous declaration_source from audit_trails |
| Fresh-database migration | integration | the audit insert fails on a database built only from the migrator |
| Public contract | table-driven | any method, route, status, field or code changes |
| Legacy responses | regression golden | the v1 renderer changes what the portal receives |
| OpenAPI agreement | table-driven | the document and the contract table disagree |
| Portal key UI | render test | the full key is shown twice, or appears in the list |
| Parallel calls | integration | more than the allowance is served, or concurrent admitted requests are miscounted |
| Rejected requests consume nothing | integration | the stored count rises past the allowance while a spent bucket is hammered, or X-RateLimit-Remaining on a 429 is not 0 |
| Hourly reset under continuous traffic | integration | a caller who never goes idle is not reset at the UTC boundary |
| Cross-instance counting | integration | two Go instances count separately, or skewed host clocks split one hour into two buckets |
| Independent keys | integration | one key's traffic consumes another key's allowance |
| 429 shape | contract | the 429 omits X-RateLimit-* or Retry-After, or Retry-After is not whole seconds to the next boundary |
| Redis outage | integration | v1 fails open, falls back in memory, 404s, crashes the process, or disturbs legacy routes — instead of 503 rate_limit_unavailable |
| Redis restart recovery | integration | live buckets are lost across a restart under the required configuration |
| Startup validation | integration | v1 serves traffic with an evicting or non-persistent limiter keyspace |
| Plugin limiter off | regression | the landlord config still rate-limits inside the plugin, or last_request stops advancing |
| Allowance bounds | unit | a create request outside 1–10000 is accepted, or the window is taken from the client |
Integration tests follow the existing recipe: pg-test on :55432, migrations run from a
directory with no .env, because backend/go/.env points at UAT. The limiter
tests additionally need a real Redis — cache/redis_client.go:20-47 tolerates an absent
one, so a suite without Redis would pass vacuously.
Verification spike — acceptance prerequisite
The installed plugin has a catch converting non-API database exceptions into
INVALID_API_KEY with no reason (index.mjs:1808-1825). Mapping that to
401 would make an integrator rotate a good key — the exact outcome the failure-mode
rule exists to prevent.
Task 3a is a spike: build request-scoped instrumentation at the adapter level that observes the key lookup and the counter update for that request, so a genuine invalid key can be told apart from an infrastructure failure.
| Injected fault | Expected result |
|---|---|
| Lookup outage — the key read fails | 503 auth_unavailable |
| Successful read, failed update — the counter write fails | 503 auth_unavailable, never 401 |
| Malformed or null response from the plugin | 503 auth_unavailable |
| Genuine unknown, disabled or expired key | 401 with the specific code |
Tasks
Regenerated after the review. The earlier 14-task list assumed one Bun endpoint, a panel UI, a shaping middleware, and no audit writer.
All 30 tasks with their verification method
| # | Task | Verify |
|---|---|---|
| 1 | Record pre-existing test baseline | numbers written down |
| 2 | Bun: landlord apiKey config — separate configId, enableSessionForAPIKeys: false, enableMetadata: true; CLI config untouched | unit |
| 3 | Bun: POST /api/internal/api-key/verify, secret-gated, config-checked | unit |
| 3a | Spike (gate): distinguish invalid key from infrastructure failure | acceptance |
| 4 | Bun: landlord key management — create, list, revoke; server-fixed prefix/mode/config/permissions; ownership and landlord role enforced | unit |
| 5 | Bun: guard the public plugin routes; reject key-derived sessions; redact key and hash | integration |
| 6 | Bun: internal listener on its own port; public port 404s the internal route | integration |
| 7 | Go: ValidateAPIKey, 503 when Bun is down | unit |
| 8 | Go: APIKeyAuthMiddleware on the v1 allowlist; no change to AuthMiddleware | unit |
| 9 | Go: account-state and role read; orgless-safe | integration |
| 10 | Go: audit context carries actor_type, api_key_id, api_key_name | unit |
| 11 | Go: RequireKeyScope | unit |
| 12 | Go: v1 error renderer — injected, sentinel-driven, pre-commit; legacy regression goldens | golden |
| 13 | Go: mount the ten v1 routes by explicit selection; photo GET returns 503 media_unavailable when the service is absent | integration |
| 14 | Go: v1 landlord gate with the v1 envelope; RequireJWTRole untouched | unit |
| 15 | Go: test-key write block | integration |
| 16 | Go: audit writer on port.Repos, rebuilt on the tx handle; rollback tests both directions | integration |
| 17 | Go: declaration_source, declaration_api_key_id, key-name snapshot on rental_properties; stamped atomically | integration |
| 18 | Go: fresh-database migration and audit-insert check | integration |
| 19 | Go: table-driven contract tests — method, route, body, query, status, codes | contract |
| 20 | Go: backend/go/openapi/rental-v1.yaml, asserted against task 19's table | contract |
| 21 | Portal: /landlord/api-keys — list, create, one-time reveal, revoke; disabled bundles with accurate labels | render tests |
| 22 | Full key lifecycle end to end | integration |
| 23 | Bun: disable the plugin limiter for the landlord config only; regression-pin last_request | regression |
| 24 | Bun: validate the allowance at creation — default 1000, min 1, configurable max (provisional 10000); window fixed server-side | unit |
| 25 | Go: Lua hourly-bucket script — Redis TIME, single declared key, atomic check-increment, expiry at the boundary | unit |
| 26 | Go: limiter middleware after verify and account state, before scope; 429 rate_limited | integration |
| 27 | Go: X-RateLimit-* and Retry-After from the atomic result; none on auth or Redis failure | contract |
| 28 | Go: Redis unavailable → 503 rate_limit_unavailable, v1 only; startup diagnostic; no fallback | integration |
| 29 | Go: startup validation of the noeviction and persistence constraint; restart-recovery test | integration |
Tasks 2–11 are the spine. Tasks 12, 19, 20 and 27 are the contract. Tasks 23–29 are the limiter. Task 21 is the only frontend work. Task 3a gates shipping, not starting: implementation proceeds, and the spike's outcome decides whether it can ship.
Out of scope for Slice 1
external_ref, address fingerprint, Idempotency-Key, cross-landlord
duplicate flagging, photo sync of any kind, proof upload over the API, outbound webhooks, the CLI,
test-row marking, listing origin, and the admin API badge. Each belongs to a later slice. Slice 1
exposes what already exists, safely.
| Also excluded by decision | Why |
|---|---|
| Redesigning CLI-key privileges | The default config keeps enableSessionForAPIKeys: true, so a CLI key remains session-capable on Bun (ADR-0010/0011/0012). Recorded as a residual risk, not fixed here. |
| Repo-wide error normalisation | Attempted before, reverted. |
| Unrelated schema cleanup | The audit column semantics use the schema as it stands. |
Note the deliberate asymmetry: the media and proofs
permissions ship in Slice 1 while their upload endpoints do not.
Permissions are stored early so a key issued today needs no reissue when Slice 3 lands.
Open risks
| Risk | Mitigation |
|---|---|
| A leaked key can submit listings in the landlord's name (D1) | Expiry default 90 days, hourly allowance, instant revoke, account-state revocation, full audit attribution, and admin review still stands between submit and live |
| Per-request cost (D11) | Accepted at current scale; the cost table states it; ponytail: comment names the ceiling; no verify cache, because revocation must be immediate |
| The limiter does not bound authentication cost | Accepted. Verification and the account-state read happen before counting, so a flooding key still costs one Bun call and the account reads per request even when it is 429'd. The limiter bounds downstream business work only. Revisit with the Slice 3 cache question. |
| Bun outage disables all key auth | Distinct 503 auth_unavailable, so integrators retry instead of rotating keys |
| Redis is now a hard dependency for v1 | 503 rate_limit_unavailable, v1 only. Legacy routes unaffected, process does not crash, no route disappears. Startup diagnostic names the degradation. |
| Counter loss on restart or eviction silently resets allowances | Not detectable at runtime — an evicted key and a fresh bucket are both simply absent. Controlled as a deployment constraint: noeviction plus persistence, validated at startup, with a restart-recovery test. |
| Boundary burst — up to 2× the allowance across a clock-hour boundary | Inherent to fixed buckets; accepted and documented in the OpenAPI description |
| A plugin database failure could masquerade as an invalid key | Task 3a spike, with a stop-and-revisit gate |
| Reusing entity structs as the public contract | Table-driven contract test per resource; a rename fails the build |
verifyApiKey internals change on plugin upgrade | Go depends on our own Bun endpoint, not on plugin internals. Every plugin claim is tied to 1.6.11 and must be re-verified on upgrade. |
| CLI-config keys stay session-capable on Bun | Out of scope by decision; revisit if the CLI gains write reach |
Follow-ups
-
Recommended ADR: the config and route trust boundary — one landlord
configIdwith sessions disabled and its plugin limiter off, v1 key-only with the hourly allowance in Go, legacy session-only. One ADR covering the whole settled boundary, now that the rate decision is made. - Revisit the verify-cache question in Slice 3, together with bulk photo import. Any proposal must answer the revocation-delay objection.
- Revisit the provisional 10000 ceiling once real usage exists. It is a deployment setting, so changing it is not a contract change.
The ownership-marker rationale stays with its source references. Not every implementation decision earns an ADR.