Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 75 additions & 1 deletion docs/client-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ paths.
- [2.2 HTTP — POST /auth](#22-http--post-auth)
- [2.3 HTTP — GET /api/userInfo (portal-service)](#23-http--get-apiuserinfo-portal-service)
- [2.4 HTTP — Protected image upload/download](#24-http--protected-image-uploaddownload)
- [2.5 HTTP — GET /api/settings.public (portal-service)](#25-http--get-apisettingspublic-portal-service)
3. [Request/Reply Methods](#3-requestreply-methods)
- [3.0 Shared schemas](#30-shared-schemas)
- [3.1 room-service](#31-room-service)
Expand Down Expand Up @@ -543,6 +544,79 @@ See [Error envelope](#6-error-envelope-reference). HTTP statuses:

---

### 2.5 HTTP — GET /api/settings.public (portal-service)

**Endpoint:** `GET /api/settings.public?query={query}&offset={offset}&count={count}`
**Reply:** synchronous HTTP response

Returns low-sensitivity, public application settings (e.g. the OpenTelemetry collector URL) for the frontend to read before login / at app boot. **Unauthenticated, discovery only — no token is validated.** Results are always ordered by `_id` ascending (there is no client-controlled sort).

**Default-deny.** A setting is returned only if its key is on the server's allowlist (`PORTAL_PUBLIC_SETTINGS_KEYS`) **and** the stored document is `public: true` and not `hidden`. A setting absent from the allowlist is never served, regardless of its stored flags. The stored `public`/`hidden` fields are filter criteria only — they are not returned.

#### Request

All parameters are optional.

| Field | Source | Type | Required | Notes |
|---|---|---|---|---|
| `query` | query | string (JSON) | no | A JSON `_id` selector restricting which settings to return: `{"_id":"otel.url"}` or `{"_id":{"$in":["otel.url","feature.x"]}}`. Keys not on the allowlist are dropped. Any non-`_id` field is ignored — a raw Mongo query is never accepted. Omitted → all allowlisted settings. |
| `offset` | query | integer | no | Pagination skip. Default `0`; negative values are clamped to `0`. |
| `count` | query | integer | no | Page size. Default `20`; `≤0` falls back to the default, values above `100` are clamped to `100`. |

```http
GET /api/settings.public?query=%7B%22_id%22%3A%22otel.url%22%7D
```

`query` is URL-encoded JSON; here it decodes to `{"_id":"otel.url"}` (fetch a single setting by key).

#### Success response

`HTTP 200`

| Field | Type | Notes |
|---|---|---|
| `settings` | [Setting](#setting)[] | The matching page of public settings. Always an array, never null. |
| `count` | integer | Number of settings in this page (`settings.length`). |
| `offset` | integer | The echoed request offset. |
| `total` | integer | Total settings matching the (allowlisted) filter, ignoring pagination. |

<a name="setting"></a>**Setting**

| Field | Type | Notes |
|---|---|---|
| `_id` | string | The setting key. |
| `value` | string \| number \| boolean | The setting value; concrete type depends on the setting. Values are scalar by convention — the `settings` collection is ops-owned and is not expected to hold array/object values. |

```json
{
"settings": [
{ "_id": "otel.url", "value": "https://otel.example.com:4318" }
],
"count": 1,
"offset": 0,
"total": 1
}
```

#### Error response

See [Error envelope](#6-error-envelope-reference). HTTP statuses:

| Status | `code` | `reason` | Example body |
|---|---|---|---|
| 400 | `bad_request` | — | `{ "code": "bad_request", "error": "count must be an integer" }` — a non-numeric `offset`/`count`, malformed `query` JSON, or a `query._id` that is neither a string nor an `{"$in": [...]}` selector (an explicit `null` is rejected). (Out-of-range numeric `offset`/`count` are clamped, not rejected.) |
| 500 | `internal` | — | `{ "code": "internal", "error": "internal error" }` |

#### Triggered events — success path

`None — HTTP-only.`

#### Triggered events — error path

`None.`

---

## 3. Request/Reply Methods

### 3.0 Shared schemas
Expand Down Expand Up @@ -5029,7 +5103,7 @@ Every error response — NATS reply subjects, JetStream async results, and HTTP

- **NATS sync replies** — on the reply subject for §3/§4 RPCs.
- **JetStream async results** — `model.AsyncJobResult` carries the same `code` + `reason` fields when `status == "error"`, so a failed async job is surfaced the same way as a sync error.
- **HTTP** — auth-service `POST /auth` (§2.2), portal-service `GET /api/userInfo` (§2.3), and upload-service's image endpoints (§2.4) write the envelope as the response body with the matching HTTP status from the table above.
- **HTTP** — auth-service `POST /auth` (§2.2), portal-service `GET /api/userInfo` (§2.3) and `GET /api/settings.public` (§2.5), and upload-service's image endpoints (§2.4) write the envelope as the response body with the matching HTTP status from the table above.

### Client branching guidance

Expand Down
11 changes: 10 additions & 1 deletion portal-service/deploy/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,14 @@ services:
{ upsert: true },
);
});
print('portal seed: hr_employee=' + p.hr_employee.countDocuments() + ' users=' + p.users.countDocuments());
// Public settings exposed via GET /api/settings.public (allowlisted below).
// Only low-sensitivity operational config belongs here — it is served unauthenticated.
p.settings.replaceOne(
{ _id: 'otel.url' },
{ _id: 'otel.url', value: 'http://localhost:4318', public: true, hidden: false },
{ upsert: true },
);
print('portal seed: hr_employee=' + p.hr_employee.countDocuments() + ' users=' + p.users.countDocuments() + ' settings=' + p.settings.countDocuments());
networks:
- chat-local

Expand Down Expand Up @@ -54,6 +61,8 @@ services:
- 'PORTAL_SITE_URLS={"site-local":{"authServiceUrl":"http://localhost:8080","baseUrl":"http://localhost:3000"}}'
- MONGO_URI=mongodb://mongodb:27017
- MONGO_DB=portal
# Default-deny allowlist of setting _ids exposable via GET /api/settings.public.
- PORTAL_PUBLIC_SETTINGS_KEYS=otel.url
networks:
- chat-local

Expand Down
8 changes: 8 additions & 0 deletions portal-service/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,14 @@ type PortalHandler struct {
devFallbackSiteID string
devFallbackNatsURL string
sites map[string]siteURL

// settings.public dependencies. Injected as fields rather than constructor
// args to avoid churning NewPortalHandler callers (same pattern as
// room-service's optional Handler deps). A nil settingsStore or empty
// allowlist makes the endpoint serve an empty set — default-deny.
settingsStore SettingsStore
publicSettingKeys []string // cleaned allowlist, order-stable
publicSettingSet map[string]struct{} // membership index for the allowlist
}

// NewPortalHandler creates a PortalHandler. devMode synthesizes a dev-site
Expand Down
11 changes: 11 additions & 0 deletions portal-service/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,12 @@ type config struct {
MongoDB string `env:"MONGO_DB" envDefault:"portal"`
MongoUsername string `env:"MONGO_USERNAME" envDefault:""`
MongoPassword string `env:"MONGO_PASSWORD" envDefault:""`

// PublicSettingKeys is the allowlist of setting _ids exposable via the
// unauthenticated GET /api/settings.public endpoint. Default-deny: a key
// absent here is never served, regardless of its `public` flag. Empty
// (the default) disables the endpoint's output entirely.
PublicSettingKeys []string `env:"PORTAL_PUBLIC_SETTINGS_KEYS" envSeparator:","`
}

func main() {
Expand Down Expand Up @@ -94,6 +100,11 @@ func run() error {
slog.Info("dev mode enabled — unknown accounts fall back to the dev site")
}

// settings.public dependencies, injected as fields (see PortalHandler).
handler.settingsStore = newMongoSettingsStore(mongoClient.Database(cfg.MongoDB))
handler.publicSettingKeys, handler.publicSettingSet = cleanSettingKeys(cfg.PublicSettingKeys)
slog.Info("public settings allowlist", "keys", len(handler.publicSettingKeys))

gin.SetMode(gin.ReleaseMode)
r := gin.New()
r.Use(gin.Recovery())
Expand Down
40 changes: 40 additions & 0 deletions portal-service/mock_store_test.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions portal-service/routes.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import "github.com/gin-gonic/gin"

func registerRoutes(r *gin.Engine, h *PortalHandler) {
r.GET("/api/userInfo", h.HandleUserInfo)
r.GET("/api/settings.public", h.HandlePublicSettings)
r.GET("/healthz", h.HandleHealth)
r.GET("/readyz", h.HandleReady)
}
178 changes: 178 additions & 0 deletions portal-service/settings_handler.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
package main

import (
"encoding/json"
"fmt"
"net/http"
"strconv"
"strings"

"github.com/gin-gonic/gin"

"github.com/hmchangw/chat/pkg/errcode"
"github.com/hmchangw/chat/pkg/errcode/errhttp"
"github.com/hmchangw/chat/pkg/mongoutil"
)

// publicSettingsResponse mirrors the Meteor settings.public envelope:
// the page of settings plus its pagination counters. Settings is always a
// JSON array, never null.
type publicSettingsResponse struct {
Settings []setting `json:"settings"`
Count int `json:"count"`
Offset int `json:"offset"`
Total int64 `json:"total"`
}

// cleanSettingKeys normalizes a raw allowlist (e.g. from
// PORTAL_PUBLIC_SETTINGS_KEYS): blanks are dropped and duplicates collapsed,
// preserving first-seen order. Returns the cleaned slice and a membership set.
func cleanSettingKeys(allowlist []string) ([]string, map[string]struct{}) {
set := make(map[string]struct{}, len(allowlist))
cleaned := make([]string, 0, len(allowlist))
for _, k := range allowlist {
k = strings.TrimSpace(k)
if k == "" {
continue
}
if _, dup := set[k]; dup {
continue
}
set[k] = struct{}{}
cleaned = append(cleaned, k)
}
return cleaned, set
}

// HandlePublicSettings returns the allowlisted public settings matching the
// request, paginated. Same wire contract as the Meteor settings.public route:
// params offset/count/sort/query, response {settings, count, offset, total}.
// Default-deny: only keys on the allowlist are ever served, so a setting
// accidentally marked public can never leak unless an operator also adds its
// key to PORTAL_PUBLIC_SETTINGS_KEYS.
func (h *PortalHandler) HandlePublicSettings(c *gin.Context) {
ctx := errcode.WithLogValues(c.Request.Context(), "request_id", c.GetString("request_id"))

q, err := parsePublicSettingsParams(c)
if err != nil {
errhttp.Write(ctx, c, err)
return
}

// Default-deny: intersect the requested keys with the allowlist. An empty
// result (or an unconfigured store) means nothing is exposable — return an
// empty page without touching the store.
q.Keys = h.allowedSettingKeys(q.Keys)
if h.settingsStore == nil || len(q.Keys) == 0 {
c.JSON(http.StatusOK, publicSettingsResponse{Settings: []setting{}, Offset: int(q.Page.Offset)})
return
}

page, err := h.settingsStore.ListPublicSettings(ctx, q)
if err != nil {
errhttp.Write(ctx, c, fmt.Errorf("list public settings: %w", err))
return
}

c.JSON(http.StatusOK, publicSettingsResponse{
Settings: page.Data,
Count: len(page.Data),
Offset: int(q.Page.Offset),
Total: page.Total,
})
}

// allowedSettingKeys intersects the caller's requested keys with the allowlist.
// A nil `requested` means no `_id` selector was given → the full allowlist is
// returned (a fresh copy, so the store can't mutate the handler's state). A
// non-nil but empty `requested` (e.g. an explicit `{"_id":{"$in":[]}}`) means
// the caller asked for nothing → empty, never the full allowlist.
func (h *PortalHandler) allowedSettingKeys(requested []string) []string {
if requested == nil {
return append([]string(nil), h.publicSettingKeys...)
}
out := make([]string, 0, len(requested))
for _, k := range requested {
if _, ok := h.publicSettingSet[k]; ok {
out = append(out, k)
}
}
return out
}

// parsePublicSettingsParams reads offset/count/sort/query from the query string.
// offset/count are validated and clamped by mongoutil.NewOffsetPageRequest
// (offset≥0, count 1–100, default 20) — the shared pagination contract. Only an
// `_id` selector in `query` is honored — any other filter field is ignored,
// since this endpoint never accepts a raw Mongo query.
func parsePublicSettingsParams(c *gin.Context) (settingsQuery, error) {
var q settingsQuery

offset, err := atoiParam(c, "offset")
if err != nil {
return q, err
}
count, err := atoiParam(c, "count")
if err != nil {
return q, err
}
q.Page = mongoutil.NewOffsetPageRequest(offset, count)

if v := c.Query("query"); v != "" {
keys, err := parseIDSelector(v)
if err != nil {
return q, err
}
q.Keys = keys
}

return q, nil
}

// atoiParam parses an optional integer query param. Absent → 0 (the caller
// passes it to NewOffsetPageRequest, which applies defaults and clamping); a
// present but non-numeric value is a client error.
func atoiParam(c *gin.Context, name string) (int, error) {
v := c.Query(name)
if v == "" {
return 0, nil
}
n, err := strconv.Atoi(v)
if err != nil {
return 0, errcode.BadRequest(name + " must be an integer")
}
return n, nil
}

// parseIDSelector extracts the requested setting keys from a `query` param. It
// accepts {"_id":"key"} or {"_id":{"$in":["a","b"]}}; a query with no `_id`
// yields no keys (the handler then serves the full allowlist). A malformed
// selector — including an explicit {"_id":null} — is a client error, the same
// as a non-string, non-$in `_id`.
func parseIDSelector(raw string) ([]string, error) {
var q struct {
ID json.RawMessage `json:"_id"`
}
if err := json.Unmarshal([]byte(raw), &q); err != nil {
return nil, errcode.BadRequest("query must be valid JSON")
}
if len(q.ID) == 0 {
return nil, nil
}

// *string (not string) so a JSON null decodes to a nil pointer and is
// rejected below, rather than silently becoming an empty-string key.
var single *string
if err := json.Unmarshal(q.ID, &single); err == nil && single != nil {
return []string{*single}, nil
}

var op struct {
In []string `json:"$in"`
}
if err := json.Unmarshal(q.ID, &op); err == nil && op.In != nil {
return op.In, nil
}

return nil, errcode.BadRequest(`query._id must be a string or an {"$in": [...]} selector`)
}
Loading
Loading