Skip to content

Commit f8c3059

Browse files
rafiki270claude
andcommitted
docs: apply loop 6 review fixes
- roles-and-acl.md: define system_admin (string value, AdminUser model, domain-hash auth, /internal/admin/ scope) - roles-and-acl.md: clarify org.customRole is derived from primary team (no independent org-scope custom role model) - roles-and-acl.md: add SCIM GET /Groups/:id endpoint to endpoint list - roles-and-acl.md: add SCIM PATCH /Groups/:id RFC 7644 Operations[] format spec (add/remove member, rename) - roles-and-acl.md: standardize group-mapping endpoints to /internal/admin/ path (resolves path conflict with api-changes-rebac.md) - roles-and-acl.md: add first-member UOA role rule for SCIM auto-created teams (first = admin, rest = member) - roles-and-acl.md: add soft-deprovision always retains overrides (scim_override_retention only affects hard-delete) - roles-and-acl.md: add name collision rule for SCIM group auto-create (skip if team name exists) - api-changes-rebac.md: clarify OrgClaim.customRole is derived convenience field, not separately stored - api-changes-rebac.md: update customRole derivation comment in OrgClaim interface - apps.md: add kill switch entry CRUD endpoints (POST/GET/PATCH/DELETE /org/:orgId/apps/:appId/killswitches) - apps.md: add activatesIn response schema for future-scheduled kill switches - feature-flags.md: add GET/POST/PATCH/DELETE response schemas for flag definition endpoints - feature-flags.md: add GET/PUT/DELETE response schemas for per-user override endpoints with source annotation - feature-flags.md: fix plain-member terminology in tiebreaker (not a valid enum value) - feature-flags.md: fix flag key format decision (remove redundant leading underscore sentence) - brief.md: add max_flags_per_app and scim_override_retention to org_features JSON, table, and Zod schema - techstack.md + architecture-api.md: fix Express/Fastify → Fastify (Fastify is the chosen framework) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 8e937ba commit f8c3059

7 files changed

Lines changed: 85 additions & 10 deletions

File tree

Docs/Auth/architecture-api.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,7 @@ For the full product spec, see [brief.md](./brief.md). For tech stack, see [tech
127127
/unit — Unit tests per service
128128
/integration — API endpoint integration tests
129129
server.ts — Server entry point
130-
app.ts — Express/Fastify app setup and middleware registration
130+
app.ts — Fastify app setup and middleware registration
131131
```
132132

133133
---

Docs/Requirements/apps.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,22 @@ Kill switches belong to an App. A kill switch entry defines a version range and
184184

185185
iOS apps provide `versionName` (CFBundleShortVersionString) and `buildNumber` (CFBundleVersion). Android apps provide `versionName` and `versionCode`. The kill switch entry specifies which field to compare against.
186186

187+
### Kill switch entry management API
188+
189+
All endpoints require org `admin` or `owner` UOA role (domain-hash auth + config JWT verification).
190+
191+
```
192+
POST /org/:orgId/apps/:appId/killswitches — create a kill switch entry
193+
GET /org/:orgId/apps/:appId/killswitches — list all entries for an App (paginated, cursor-based)
194+
GET /org/:orgId/apps/:appId/killswitches/:id — get a single entry
195+
PATCH /org/:orgId/apps/:appId/killswitches/:id — update entry fields (all fields except id, appId, createdAt)
196+
DELETE /org/:orgId/apps/:appId/killswitches/:id — delete an entry
197+
```
198+
199+
`POST` body accepts all kill switch entry fields (`platform`, `type`, `versionField`, `versionValue`, `versionMax`, `versionScheme`, `name`, `titleKey`, `title`, `messageKey`, `message`, `primaryButtonKey`, `primaryButton`, `secondaryButtonKey`, `secondaryButton`, `latestVersion`, `active`, `activateAt`, `deactivateAt`, `priority`, `testUserIds`, `cacheTtl`). `platform`, `type`, `versionField`, `versionValue`, and `type` are required; all others are optional. Returns HTTP 201 on creation with the full entry.
200+
201+
`DELETE` is idempotent — deleting a non-existent entry returns HTTP 404.
202+
187203
### Kill switch query API
188204

189205
**Authentication:** This endpoint is intentionally public (no bearer token required). It identifies the app by `appIdentifier` (a registered, non-secret identifier). Optionally the SDK may attach the domain-hash bearer token for the org's domain if available, but it is not required. The `userId` param, if provided, must be a valid user ID — it is not authenticated here (used only for test mode targeting).
@@ -220,6 +236,18 @@ Response when blocked:
220236
}
221237
```
222238

239+
Response when a kill switch has a future `activateAt` (not yet blocking, but approaching):
240+
241+
```json
242+
{
243+
"status": "ok",
244+
"activatesIn": 540,
245+
"cacheTtl": 540
246+
}
247+
```
248+
249+
`activatesIn` is the number of seconds until the nearest pending kill switch activates. When `activatesIn ≤ 900` (15 minutes), `cacheTtl` is capped to `activatesIn` so the SDK re-polls before activation. The kill switch entry is not present in the response until it activates.
250+
223251
Response when clear:
224252

225253
```json

Docs/Requirements/feature-flags.md

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ When a consuming app queries a flag for a user, resolution proceeds in order and
4545

4646
The global missing-flag default means consuming apps never get an error for an undefined flag — they always get a boolean.
4747

48-
**Multi-team context:** A user may have different `customRole` values on different teams within the same org, producing different role flag values. The flag query must specify a team context (`teamId` param) when a user has more than one team membership. If no `teamId` is provided and the user has exactly one team membership, that team's role is used. If no `teamId` is provided and the user has multiple memberships, the tiebreaker is applied in this order: (1) highest UOA system role on that team (`owner > admin > plain-member`), (2) if equal, earliest `createdAt` on the `TeamMember` record (the team the user joined first). This tiebreaker uses the effective UOA role (including org-level inheritance).
48+
**Multi-team context:** A user may have different `customRole` values on different teams within the same org, producing different role flag values. The flag query must specify a team context (`teamId` param) when a user has more than one team membership. If no `teamId` is provided and the user has exactly one team membership, that team's role is used. If no `teamId` is provided and the user has multiple memberships, the tiebreaker is applied in this order: (1) highest UOA system role on that team (`owner > admin`, with users having no named UOA role ranking lowest), (2) if equal, earliest `createdAt` on the `TeamMember` record (the team the user joined first). This tiebreaker uses the effective UOA role (including org-level inheritance). Note: "no named UOA role" (plain member) is not a valid enum value — it simply means neither `owner` nor `admin` is assigned.
4949

5050
### Flag query API
5151

@@ -174,6 +174,20 @@ PATCH /apps/:appId/flags/definitions/:flagKey — update a flag (body: { d
174174
DELETE /apps/:appId/flags/definitions/:flagKey — delete a flag (cascades: role assignments, user overrides)
175175
```
176176

177+
`GET` response (HTTP 200):
178+
```json
179+
[
180+
{ "key": "dark_mode", "description": "Dark mode UI", "defaultState": "disabled", "createdAt": "2024-01-15T10:00:00Z" },
181+
{ "key": "new_checkout", "description": "New checkout flow", "defaultState": "enabled", "createdAt": "2024-01-16T08:30:00Z" }
182+
]
183+
```
184+
185+
`POST` response (HTTP 201): same shape as a single element from the `GET` response array. Returns HTTP 400 with `{ "error": "Request failed" }` if the key is invalid format, already exists, or the App's `max_flags_per_app` cap is reached.
186+
187+
`PATCH` response (HTTP 200): updated flag object. `PATCH` with an empty body `{}` returns HTTP 400. Unknown fields in body return HTTP 400.
188+
189+
`DELETE` response: HTTP 204 (no body).
190+
177191
### Role matrix API endpoints
178192

179193
```
@@ -192,6 +206,21 @@ DELETE /apps/:appId/flags/overrides/:userId/:flagKey — remove a specific over
192206
DELETE /apps/:appId/flags/overrides/:userId — remove all overrides for a user
193207
```
194208

209+
`GET` response (HTTP 200):
210+
```json
211+
{
212+
"dark_mode": { "value": true, "source": "override" },
213+
"new_checkout": { "value": false, "source": "role" },
214+
"beta_access": { "value": false, "source": "default" }
215+
}
216+
```
217+
218+
Source values: `"override"` (explicit per-user assignment), `"role"` (from role matrix), `"default"` (flag's `defaultState`). An unknown `userId` or a `userId` not belonging to this org's App returns HTTP 200 with `{}` (no overrides, no information leak).
219+
220+
`PUT` response (HTTP 200): the updated resolved flag map for the user, same shape as above.
221+
222+
`DELETE` (single flag) response: HTTP 204. `DELETE` (all overrides) response: HTTP 204.
223+
195224
### Feature flags service enablement
196225

197226
Feature flags are enabled per **App** (not per-org globally). Two fields control availability:
@@ -207,7 +236,7 @@ Feature flags are enabled per **App** (not per-org globally). Two fields control
207236

208237
## Resolved decisions
209238

210-
1. **Flag key format****decided: lowercase letters, digits, and underscores only; must start with a letter** (`[a-z][a-z0-9_]*`). Examples: `dark_mode`, `can_publish`, `beta_access`. Enforced at creation. Validation rejects any other format with a 400 error. This applies to all flags across all Apps. Leading underscores are not allowed.
239+
1. **Flag key format****decided: must start with a lowercase letter, followed by lowercase letters, digits, or underscores** (regex: `[a-z][a-z0-9_]*`). Examples: `dark_mode`, `can_publish`, `beta_access`. Enforced at creation. Validation rejects any other format with HTTP 400. This applies to all flags across all Apps.
211240
2. **Token flag inclusion****decided: all flags for the App are included in the token at login time**, regardless of whether they differ from the default. This keeps token consumption simple (no server-side re-resolution needed on read). Orgs with many flags should use `max_flags_per_app` config to cap token size (default 100; see `org_features` in brief.md).
212241
3. **Real-time flag changes****decided: poll only**. The token embeds flag state at login. Mid-session changes are visible only via `/apps/:appId/flags` query endpoint calls. The SDK polls on foreground resume (default interval: 5 minutes, minimum 60 seconds, configurable per App). No push mechanism.
213242
4. **SCIM flag sync / override retention****decided: soft-deprovision by default**. When a SCIM user is deprovisioned (`active: false`), their per-user flag overrides are **retained** and are re-linked when the user is re-provisioned (matched by email or SCIM `externalId`). Overrides are only deleted when a user is hard-deleted (`DELETE /scim/v2/Users/:id` with `?hardDelete=true` query param, or when the org is deleted). This is the default; orgs can configure `scim_override_retention: "retain" | "clear"` in their `org_features`.

Docs/Requirements/roles-and-acl.md

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ These control who can administer the UOA backend itself — the org and team str
1515

1616
Users who are neither `owner` nor `admin` have no named UOA system role — they are plain members whose significance is defined entirely by their custom role.
1717

18-
`system_admin` is a separate global role for UOA's own admin panel operators and is not visible to org/team users.
18+
`system_admin` is a separate global role for UOA's own admin panel operators and is not visible to org/team users. It is stored as the string value `"system_admin"` on the internal `AdminUser` model (separate from `OrgMember`/`TeamMember`). System admin identity is verified via domain-hash auth against the admin domain + the UOA `SHARED_SECRET` (same mechanism as other internal routes). Endpoints under `/internal/admin/` require system admin auth and bypass all org/team permission middleware.
1919

2020
### 2. Consumer-defined roles (external, custom)
2121

@@ -140,10 +140,10 @@ On rename: existing `TeamMember.customRole` records with the old name are update
140140
- `method` — the authentication method used: `"email"`, `"google"`, `"github"`, `"microsoft"`
141141
- `uoaRole``owner`, `admin`, or omitted if neither (plain member has no named UOA role)
142142
- `uoaRoleInherited``true` if derived from org-level role rather than explicit team assignment; omitted if `false`
143-
- `customRole` (org level) — the consuming app's role label for this user at the org level. Since org-level custom roles are independent of team roles, this reflects any role explicitly set at the org scope. Omitted if none assigned at org level.
143+
- `customRole` (org level) — a convenience field. Derived from the user's primary team `customRole` using the same tiebreaker as flag resolution (highest UOA system role → earliest `TeamMember.createdAt`). There is no separately stored org-level custom role — this field is computed, not read from a separate org-scope data model. Omitted if no team has a `customRole` set for this user.
144144
- `customRole` (team level) — the consuming app's single role label for this user on this specific team membership. Omitted if no custom role is assigned on that team.
145145

146-
**`org.customRole` when the user has multiple team memberships:** The org-level `customRole` is set explicitly (not derived from team roles). If the user has no explicit org-level custom role, `org.customRole` is omitted. Team-level `customRole` values are always per-team in the `teams[]` array and are never aggregated to the org level. See decision in the role model section below.
146+
**`org.customRole` when the user has multiple team memberships:** Always derived from the primary team's `customRole` using the standard multi-team tiebreaker. It is never independently set or stored at org scope. Custom roles are team-only constructs.
147147

148148
---
149149

@@ -220,14 +220,15 @@ GET /scim/v2/Users/:id — read user
220220
PATCH /scim/v2/Users/:id — update user attributes / active status
221221
DELETE /scim/v2/Users/:id — deprovision user (see deprovisioning behavior below)
222222
GET /scim/v2/Groups — list groups/teams (paginated, cursor-based)
223+
GET /scim/v2/Groups/:id — read a single group/team
223224
POST /scim/v2/Groups — create team via IdP
224225
PATCH /scim/v2/Groups/:id — add/remove members, rename team
225226
DELETE /scim/v2/Groups/:id — delete team
226227
```
227228

228229
**SCIM bearer token:** An opaque UUID token issued per org via the admin panel. It has no expiry by default (long-lived). A single org may have multiple active tokens (for rolling rotation or multiple IdP integrations). Tokens are stored hashed; plain value shown only at creation. Revoked via admin panel (`DELETE /internal/admin/orgs/:orgId/scim-tokens/:tokenId`). Scoped to a single org — cannot be used across orgs.
229230

230-
**Group → team mapping:** SCIM Groups map to UOA teams. Mapping is stored explicitly: a `ScimGroupMapping` record links an IdP `externalGroupId` to a UOA `teamId`. Endpoints for managing mappings: `GET/POST/DELETE /org/:orgId/scim/group-mappings`. If a SCIM Group arrives with no mapping, UOA auto-creates a new team with the group `displayName` as the team name, and creates a mapping automatically. Deleting a mapping does not delete the team — only severs the auto-sync link.
231+
**Group → team mapping:** SCIM Groups map to UOA teams. Mapping is stored explicitly: a `ScimGroupMapping` record links an IdP `externalGroupId` to a UOA `teamId`. Endpoints for managing mappings (system admin auth required): `GET/POST/DELETE /internal/admin/orgs/:orgId/scim/group-mappings` (see also `api-changes-rebac.md §6`). If a SCIM Group arrives with no mapping, UOA auto-creates a new team with the group `displayName` as the team name (if a team with that name already exists in the org, auto-creation is skipped and the SCIM operation continues without team assignment for that group), and creates a mapping automatically. When a SCIM group auto-creates a team, the first member added in the provisioning request receives `admin` UOA team role; all subsequent members receive `member`. Deleting a mapping does not delete the team — only severs the auto-sync link.
231232

232233
**User attribute mapping:**
233234
- `userName` → UOA `email` (UPN format like `alice@ford.com`)
@@ -289,8 +290,18 @@ SCIM endpoints are authenticated with the per-org SCIM bearer token (see above).
289290

290291
**SCIM `GET /scim/v2/Users` pagination:** Uses SCIM standard `startIndex` (1-based, default 1) and `count` (page size, default 100, max 200) params. Supports `filter=userName eq "alice@acme.com"` and `filter=externalId eq "<idp-id>"` per RFC 7644 §3.4.2.2. Response includes `totalResults`, `startIndex`, `itemsPerPage`, and a `Resources` array of User objects.
291292

293+
**SCIM `GET /scim/v2/Groups/:id` response:** Returns a SCIM Group object including `id`, `displayName`, `externalId` (IdP group ID), and a `members[]` array of `{ "value": "<scimUserId>", "display": "<userName>" }` objects for current team members.
294+
295+
**SCIM `PATCH /scim/v2/Groups/:id` format:** Uses RFC 7644 §3.5.2 `Operations[]` array format. Three supported operations:
296+
- Add member: `{ "op": "Add", "path": "members", "value": [{"value": "<userId>"}] }`
297+
- Remove member: `{ "op": "Remove", "path": "members[value eq \"<userId>\"]" }`
298+
- Rename team: `{ "op": "Replace", "path": "displayName", "value": "New Team Name" }`
299+
Multiple operations may appear in a single PATCH body. Operations are applied atomically. Team rename via SCIM is authoritative — if a UOA admin renamed the team, the next SCIM PATCH with a `displayName` replace will overwrite it.
300+
292301
**SCIM `GET /scim/v2/Groups` pagination:** Uses SCIM standard `startIndex` (1-based, default 1) and `count` (page size, default 100, max 200) params. Supports `filter=displayName eq "Engineering"` per RFC 7644 §3.4.2.2. Response includes `totalResults`, `startIndex`, `itemsPerPage`.
293302

303+
**Override retention on soft-deprovision:** Per-user flag overrides are **always retained** on soft-deprovision (`PATCH { active: false }` or `DELETE` without `?hardDelete=true`), regardless of the `scim_override_retention` org config. The `scim_override_retention` config only controls what happens on hard-delete (`DELETE?hardDelete=true`).
304+
294305
**SCIM bearer token management endpoints** (system admin auth required):
295306
```
296307
GET /internal/admin/orgs/:orgId/scim-tokens — list all tokens (id, label, createdAt, lastUsedAt; plain token never returned after creation)

Docs/Research/api-changes-rebac.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -315,7 +315,7 @@ interface OrgClaim {
315315
id: string;
316316
slug: string;
317317
uoaRole?: 'owner' | 'admin'; // omitted if plain member
318-
customRole?: string; // consuming app's role label; omitted if none
318+
customRole?: string; // the customRole from the user's primary team (tiebreaker: highest UOA system role → earliest TeamMember.createdAt); omitted if no team customRole is set. This is a convenience field derived from the teams[] array — it is NOT a separately stored org-level role. There is no org-scoped custom role data model.
319319
teams: TeamClaim[];
320320
}
321321

Docs/brief.md

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -581,7 +581,9 @@ The claim is optional and defaults to disabled. The object shape and defaults ar
581581
"max_members_per_team": 200,
582582
"max_members_per_group": 500,
583583
"max_team_memberships_per_user": 50,
584-
"org_roles": ["owner", "admin", "member"]
584+
"org_roles": ["owner", "admin", "member"],
585+
"max_flags_per_app": 100,
586+
"scim_override_retention": "retain"
585587
}
586588
```
587589

@@ -597,6 +599,8 @@ The claim is optional and defaults to disabled. The object shape and defaults ar
597599
| `max_members_per_group` | integer | `500` | Maximum members per group (max 5000) |
598600
| `max_team_memberships_per_user` | integer | `50` | Maximum teams a single user can belong to — also caps JWT size (max 200) |
599601
| `org_roles` | string[] | `["owner", "admin", "member"]` | Allowed org-level roles. Must always contain `"owner"`. |
602+
| `max_flags_per_app` | integer | `100` | Maximum feature flag definitions per App (max 500). Enforced at creation; existing flags unaffected if cap is lowered. |
603+
| `scim_override_retention` | `"retain"` \| `"clear"` | `"retain"` | Controls per-user flag override retention on SCIM hard-delete (`DELETE /scim/v2/Users/:id?hardDelete=true`). `"retain"` keeps overrides; `"clear"` deletes them. Soft-deprovision always retains overrides regardless of this setting. |
600604

601605
* `enabled = false` (or omitted): all `/org/*` and `/internal/org/*` endpoints return `404`, access tokens omit `org` claims.
602606
* `groups_enabled = false`: group read/write paths return `404`.
@@ -620,12 +624,15 @@ org_features: z.object({
620624
(roles) => roles.includes('owner'),
621625
{ message: 'org_roles must include "owner"' }
622626
).default(['owner', 'admin', 'member']),
627+
max_flags_per_app: z.number().int().positive().max(500).default(100),
628+
scim_override_retention: z.enum(['retain', 'clear']).default('retain'),
623629
}).optional().default({
624630
enabled: false, groups_enabled: false, user_needs_team: false,
625631
max_teams_per_org: 100, max_groups_per_org: 20,
626632
max_members_per_org: 1000, max_members_per_team: 200,
627633
max_members_per_group: 500, max_team_memberships_per_user: 50,
628634
org_roles: ['owner', 'admin', 'member'],
635+
max_flags_per_app: 100, scim_override_retention: 'retain',
629636
})
630637
```
631638

Docs/techstack.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ The API is the central OAuth/auth server. It handles:
3030

3131
```
3232
/API
33-
/routes — Express/Fastify route handlers
33+
/routes — Fastify route handlers
3434
/routes/org — User-facing org/team routes
3535
/routes/internal/org — Internal org-team-group admin routes
3636
/middleware — Auth, config verification, error handling

0 commit comments

Comments
 (0)