@nacre.work/api 0.4.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/server.js CHANGED
@@ -8,6 +8,7 @@ import { authenticate, rejectTenantOverride } from './auth.js';
8
8
  import { badRequest, internal, notFound, Problem } from './errors.js';
9
9
  import { isConflict, isReplay } from './idempotency.js';
10
10
  import { limitHeaders } from './limits.js';
11
+ import { looksLikeEmail } from './principals.js';
11
12
  import { clientSource } from './source.js';
12
13
  import { auditFormat, auditJson, readAuditQuery, toCsv, toNdjson, } from './audit-export.js';
13
14
  import { readPage } from './pagination.js';
@@ -55,6 +56,25 @@ function accountJson(a) {
55
56
  revoked_at: a.revokedAt,
56
57
  };
57
58
  }
59
+ function userJson(u) {
60
+ return {
61
+ id: u.id,
62
+ email: u.email,
63
+ role: u.role,
64
+ created_at: u.createdAt,
65
+ disabled_at: u.disabledAt,
66
+ // Whether one is set, never anything derived from it. False is an SSO-only
67
+ // account, which is a fact an administrator needs and which says nothing
68
+ // about the credential.
69
+ has_password: u.hasPassword,
70
+ };
71
+ }
72
+ function groupJson(g) {
73
+ return { id: g.id, name: g.name, created_at: g.createdAt, member_count: g.memberCount };
74
+ }
75
+ function memberJson(m) {
76
+ return { type: m.type, id: m.id, label: m.label };
77
+ }
58
78
  function grantJson(g) {
59
79
  return {
60
80
  id: g.id,
@@ -81,6 +101,8 @@ function grantJson(g) {
81
101
  * this build does not have, which is not a question about whether an object
82
102
  * exists, so invariant I4 has no bearing and saying so plainly is right.
83
103
  */
104
+ /** Shape only. Existence is the adapter's, and answers `404` rather than `400`. */
105
+ const UUID_SHAPE = /^[0-9a-f-]{36}$/i;
84
106
  function parseGrant(body) {
85
107
  const principalType = body.principal_type;
86
108
  const principalId = body.principal_id;
@@ -91,6 +113,24 @@ function parseGrant(body) {
91
113
  if (typeof principalId !== 'string' || typeof scopeId !== 'string') {
92
114
  return "'principal_id' and 'scope_id' are required.";
93
115
  }
116
+ // Shape, here, and existence in the adapter — because the two answers are
117
+ // different and only one of them is allowed to be specific.
118
+ //
119
+ // A value that is not a uuid is a fact about the caller's own request: it
120
+ // discloses nothing, so it gets a `400` naming the field they got wrong.
121
+ // A well-formed uuid that names nothing is `404`, indistinguishable from one
122
+ // they may not administer, which is invariant 4.
123
+ //
124
+ // Collapsing the two is what sent somebody looking at the wrong half of a
125
+ // form: they typed a service account's *name* into `principal_id`, and the
126
+ // only answer was "no such scope" — about the field that was correct.
127
+ if (!UUID_SHAPE.test(principalId)) {
128
+ return "'principal_id' must be a uuid. It is the principal's id, not its name — " +
129
+ 'a service account, a user or a group listed in this organization.';
130
+ }
131
+ if (!UUID_SHAPE.test(scopeId)) {
132
+ return "'scope_id' must be a uuid — the id of the workspace or layer being granted on.";
133
+ }
94
134
  if (typeof principalType !== 'string' || !PRINCIPAL_TYPES.includes(principalType)) {
95
135
  return `'principal_type' must be one of ${PRINCIPAL_TYPES.join(', ')}.`;
96
136
  }
@@ -699,6 +739,32 @@ async function handle(req, res, options) {
699
739
  await handleAuth(req, res, instance, requestId, options);
700
740
  return;
701
741
  }
742
+ // A path this server does not route is `404`, and it says so **before**
743
+ // asking for a credential.
744
+ //
745
+ // Everything unauthenticated is already handled above — `/metrics`, the two
746
+ // `/.well-known` documents, health, readiness, sign-in. So anything left that
747
+ // is not under `/v1/` is not part of this API at all, and answering `401`
748
+ // for it claims a path exists and is merely gated.
749
+ //
750
+ // That is not a hypothetical reading. A client pointed at this port looking
751
+ // for an MCP endpoint probed `/.well-known/oauth-authorization-server`,
752
+ // `/.well-known/openid-configuration` and `/register`, got `401 "A bearer
753
+ // token is required"` from each, and concluded the deployment had OAuth
754
+ // discovery behind a middleware that needed lifting. There is no such
755
+ // middleware and there are no such routes: this is a resource server and
756
+ // declines the authorization-server role — see docs/mcp.md. An afternoon
757
+ // went into un-gating endpoints that do not exist.
758
+ //
759
+ // Unknown paths *under* `/v1/` still answer `401`, and that is the line
760
+ // rather than an omission: they are inside the authenticated surface, where
761
+ // presenting a credential is the price of being told anything. Nothing is
762
+ // concealed by it — every route this API serves is in docs/openapi.yaml.
763
+ if (!instance.startsWith('/v1/')) {
764
+ const problem = notFound(instance, requestId);
765
+ send(res, problem.status, problem.toJSON(), requestId);
766
+ return;
767
+ }
702
768
  const auth = await authenticate(req.headers.authorization, options.verify, instance, requestId);
703
769
  if (auth instanceof Problem) {
704
770
  // Counted by what was presented, never by why it failed.
@@ -1549,6 +1615,35 @@ async function handle(req, res, options) {
1549
1615
  send(res, 204, null, requestId);
1550
1616
  return;
1551
1617
  }
1618
+ if (req.method === 'DELETE' && layerMatch !== null) {
1619
+ const layerId = layerMatch[1];
1620
+ if (options.layers?.remove === undefined) {
1621
+ const problem = notFound(instance, requestId);
1622
+ send(res, problem.status, problem.toJSON(), requestId);
1623
+ return;
1624
+ }
1625
+ const removed = await options.layers.remove(auth, layerId);
1626
+ // Recorded whichever way it went, and before the answer. Deleting a layer
1627
+ // takes every document in it out of every answer at once, which is the
1628
+ // largest single thing a caller can do here — a refused attempt is worth
1629
+ // as much to an investigation as a successful one.
1630
+ await options.audit.write({
1631
+ orgId: auth.orgId,
1632
+ actor: `${auth.principal.type}:${auth.principal.id}`,
1633
+ action: 'delete_layer',
1634
+ result: removed ? 'allow' : 'deny',
1635
+ target: { layer_id: layerId },
1636
+ detail: { layer_id: layerId },
1637
+ requestId,
1638
+ });
1639
+ if (!removed) {
1640
+ const problem = notFound(instance, requestId);
1641
+ send(res, problem.status, problem.toJSON(), requestId);
1642
+ return;
1643
+ }
1644
+ send(res, 204, null, requestId);
1645
+ return;
1646
+ }
1552
1647
  // `/v1/admin/...` — routes a commercial module mounted.
1553
1648
  //
1554
1649
  // After authentication and after `rejectTenantOverride`, deliberately. A
@@ -1922,6 +2017,389 @@ async function handle(req, res, options) {
1922
2017
  send(res, 204, null, requestId);
1923
2018
  return;
1924
2019
  }
2020
+ // ───────────────────────────── principals ─────────────────────────────
2021
+ //
2022
+ // Users and groups. `org_admin`, on the same argument service accounts
2023
+ // make: a principal belongs to the organization rather than to a scope
2024
+ // inside it, so there is nothing to check `admin` against — and someone
2025
+ // holding admin on one layer must not be able to mint one.
2026
+ //
2027
+ // The refusal writes a `deny` event. It surfaces as `404`, which is what
2028
+ // makes it easy to miss: an early return that answers "no such path" is
2029
+ // still a refusal, and `docs/audit.md` counts every one.
2030
+ const principalPath = /^\/v1\/(users|groups)(\/.*)?$/.exec(instance);
2031
+ if (principalPath !== null) {
2032
+ const port = principalPath[1] === 'users' ? options.users : options.groups;
2033
+ if (port !== undefined && auth.role !== 'org_admin') {
2034
+ await options.audit.write({
2035
+ orgId: auth.orgId,
2036
+ actor: `${auth.principal.type}:${auth.principal.id}`,
2037
+ action: 'administer_principals',
2038
+ result: 'deny',
2039
+ detail: { path: instance, method: req.method ?? 'GET', reason: 'not an org_admin' },
2040
+ requestId,
2041
+ });
2042
+ const problem = notFound(instance, requestId);
2043
+ send(res, problem.status, problem.toJSON(), requestId);
2044
+ return;
2045
+ }
2046
+ }
2047
+ if (instance === '/v1/users' && options.users !== undefined) {
2048
+ if (req.method === 'GET') {
2049
+ const page = readPage(url.searchParams, instance, requestId);
2050
+ if (page instanceof Problem) {
2051
+ send(res, page.status, page.toJSON(), requestId);
2052
+ return;
2053
+ }
2054
+ const { items, nextCursor } = await options.users.list(auth, page);
2055
+ send(res, 200, { items: items.map(userJson), next_cursor: nextCursor }, requestId);
2056
+ return;
2057
+ }
2058
+ if (req.method === 'POST') {
2059
+ const fields = (body ?? {});
2060
+ const email = typeof fields.email === 'string' ? fields.email.trim() : '';
2061
+ if (!looksLikeEmail(email)) {
2062
+ const problem = badRequest(instance, requestId, "'email' is required and must be an address.");
2063
+ send(res, problem.status, problem.toJSON(), requestId);
2064
+ return;
2065
+ }
2066
+ const role = fields.role ?? 'member';
2067
+ // `platform_admin` is deliberately not creatable here, and this is the
2068
+ // one refusal in this block that is about the model rather than about
2069
+ // input. That role administers the *installation* and spans tenants in
2070
+ // the multi-tenancy module; an org_admin minting one would be
2071
+ // escalating out of their own organization through an endpoint scoped
2072
+ // to it. It is set by whoever runs `init`, and stays there.
2073
+ if (role !== 'member' && role !== 'org_admin') {
2074
+ const problem = badRequest(instance, requestId, "'role' must be 'member' or 'org_admin'. 'platform_admin' administers the " +
2075
+ 'installation rather than this organization and is not issued here.');
2076
+ send(res, problem.status, problem.toJSON(), requestId);
2077
+ return;
2078
+ }
2079
+ const created = await options.users.create(auth, email, role);
2080
+ if (created === undefined) {
2081
+ await options.audit.write({
2082
+ orgId: auth.orgId,
2083
+ actor: `${auth.principal.type}:${auth.principal.id}`,
2084
+ action: 'create_user',
2085
+ result: 'deny',
2086
+ detail: { email, reason: 'address taken' },
2087
+ requestId,
2088
+ });
2089
+ const problem = new Problem({
2090
+ type: 'https://nacre.work/errors/conflict',
2091
+ title: 'Conflict',
2092
+ status: 409,
2093
+ detail: `A user with the address '${email}' already exists in this organization.`,
2094
+ instance,
2095
+ requestId,
2096
+ });
2097
+ send(res, problem.status, problem.toJSON(), requestId);
2098
+ return;
2099
+ }
2100
+ await options.audit.write({
2101
+ orgId: auth.orgId,
2102
+ actor: `${auth.principal.type}:${auth.principal.id}`,
2103
+ action: 'create_user',
2104
+ result: 'allow',
2105
+ // The address and the role. Never the password — this row is readable
2106
+ // by anyone with the audit log, and the password is not recoverable
2107
+ // from anywhere else by design.
2108
+ detail: { user_id: created.user.id, email, role },
2109
+ requestId,
2110
+ });
2111
+ // The only time the password exists outside the caller's process.
2112
+ send(res, 201, { ...userJson(created.user), password: created.password }, requestId);
2113
+ return;
2114
+ }
2115
+ }
2116
+ const userMatch = /^\/v1\/users\/([^/]+)$/.exec(instance);
2117
+ if (userMatch && options.users !== undefined) {
2118
+ const id = decodeURIComponent(userMatch[1]);
2119
+ if (req.method === 'DELETE') {
2120
+ // Disabled, never deleted, which is what `DELETE` means on every
2121
+ // removable thing here: a document is tombstoned, a key is revoked, and
2122
+ // a user keeps their row because the audit log names its id and
2123
+ // `grants.created_by` references it with no cascade. `PATCH` with
2124
+ // `disabled: false` is how somebody comes back.
2125
+ //
2126
+ // Through the same call `PATCH` makes rather than a second statement,
2127
+ // so the last-administrator guard covers both spellings. Two removals
2128
+ // with one check between them is how the guarded one gets routed
2129
+ // around.
2130
+ const disabled = await options.users.update(auth, id, { disabled: true });
2131
+ await options.audit.write({
2132
+ orgId: auth.orgId,
2133
+ actor: `${auth.principal.type}:${auth.principal.id}`,
2134
+ action: 'disable_user',
2135
+ result: disabled === 'updated' ? 'allow' : 'deny',
2136
+ detail: { user_id: id, ...(disabled === 'updated' ? {} : { reason: disabled }) },
2137
+ requestId,
2138
+ });
2139
+ if (disabled === 'last-admin') {
2140
+ const problem = new Problem({
2141
+ type: 'https://nacre.work/errors/conflict',
2142
+ title: 'Conflict',
2143
+ status: 409,
2144
+ detail: 'This is the only active org_admin in the organization. Promote another user ' +
2145
+ 'first — an organization with none has no route back through the API.',
2146
+ instance,
2147
+ requestId,
2148
+ });
2149
+ send(res, problem.status, problem.toJSON(), requestId);
2150
+ return;
2151
+ }
2152
+ if (disabled === 'no-user') {
2153
+ const problem = notFound(instance, requestId);
2154
+ send(res, problem.status, problem.toJSON(), requestId);
2155
+ return;
2156
+ }
2157
+ send(res, 204, null, requestId);
2158
+ return;
2159
+ }
2160
+ if (req.method === 'PATCH') {
2161
+ const fields = (body ?? {});
2162
+ const wantsRole = 'role' in fields;
2163
+ const wantsDisabled = 'disabled' in fields;
2164
+ if (!wantsRole && !wantsDisabled) {
2165
+ const problem = badRequest(instance, requestId, "Give at least one of 'role' or 'disabled'.");
2166
+ send(res, problem.status, problem.toJSON(), requestId);
2167
+ return;
2168
+ }
2169
+ if (wantsRole && fields.role !== 'member' && fields.role !== 'org_admin') {
2170
+ const problem = badRequest(instance, requestId, "'role' must be 'member' or 'org_admin'. 'platform_admin' administers the " +
2171
+ 'installation rather than this organization and is not issued here.');
2172
+ send(res, problem.status, problem.toJSON(), requestId);
2173
+ return;
2174
+ }
2175
+ if (wantsDisabled && typeof fields.disabled !== 'boolean') {
2176
+ const problem = badRequest(instance, requestId, "'disabled' must be a boolean.");
2177
+ send(res, problem.status, problem.toJSON(), requestId);
2178
+ return;
2179
+ }
2180
+ // The last administrator cannot demote or disable themselves through
2181
+ // this path. Not paternalism: an organization with no `org_admin` has
2182
+ // no route back — every endpoint that could restore one is behind the
2183
+ // role that was just given up, and the remedy would be SQL. The check
2184
+ // is in the adapter, where it can count in the same transaction.
2185
+ const changed = await options.users.update(auth, id, {
2186
+ ...(wantsRole ? { role: fields.role } : {}),
2187
+ ...(wantsDisabled ? { disabled: fields.disabled } : {}),
2188
+ });
2189
+ await options.audit.write({
2190
+ orgId: auth.orgId,
2191
+ actor: `${auth.principal.type}:${auth.principal.id}`,
2192
+ action: 'update_user',
2193
+ result: changed === 'updated' ? 'allow' : 'deny',
2194
+ detail: {
2195
+ user_id: id,
2196
+ ...(wantsRole ? { role: fields.role } : {}),
2197
+ ...(wantsDisabled ? { disabled: fields.disabled } : {}),
2198
+ ...(changed === 'updated' ? {} : { reason: changed }),
2199
+ },
2200
+ requestId,
2201
+ });
2202
+ if (changed === 'last-admin') {
2203
+ // 409 rather than 404: the caller is looking straight at this user —
2204
+ // it is their own account or one they just listed — so the answer is
2205
+ // about the organization's state and not about what they can see.
2206
+ // Invariant 4 is about invisibility, and nothing here is invisible.
2207
+ const problem = new Problem({
2208
+ type: 'https://nacre.work/errors/conflict',
2209
+ title: 'Conflict',
2210
+ status: 409,
2211
+ detail: 'This is the only active org_admin in the organization. Promote another user ' +
2212
+ 'first — an organization with none has no route back through the API.',
2213
+ instance,
2214
+ requestId,
2215
+ });
2216
+ send(res, problem.status, problem.toJSON(), requestId);
2217
+ return;
2218
+ }
2219
+ if (changed === 'no-user') {
2220
+ const problem = notFound(instance, requestId);
2221
+ send(res, problem.status, problem.toJSON(), requestId);
2222
+ return;
2223
+ }
2224
+ send(res, 204, null, requestId);
2225
+ return;
2226
+ }
2227
+ }
2228
+ const passwordMatch = /^\/v1\/users\/([^/]+)\/password$/.exec(instance);
2229
+ if (req.method === 'POST' && passwordMatch && options.users !== undefined) {
2230
+ const id = decodeURIComponent(passwordMatch[1]);
2231
+ const password = await options.users.resetPassword(auth, id);
2232
+ await options.audit.write({
2233
+ orgId: auth.orgId,
2234
+ actor: `${auth.principal.type}:${auth.principal.id}`,
2235
+ action: 'reset_password',
2236
+ result: password === undefined ? 'deny' : 'allow',
2237
+ // That it happened and to whom. Never the value.
2238
+ detail: { user_id: id },
2239
+ requestId,
2240
+ });
2241
+ if (password === undefined) {
2242
+ const problem = notFound(instance, requestId);
2243
+ send(res, problem.status, problem.toJSON(), requestId);
2244
+ return;
2245
+ }
2246
+ // Generated rather than chosen, for the reason `init` gives: an argument
2247
+ // ends up in a shell history, and a password an administrator picked is
2248
+ // one they know. This is the only time it exists outside the process.
2249
+ send(res, 200, { password }, requestId);
2250
+ return;
2251
+ }
2252
+ if (instance === '/v1/groups' && options.groups !== undefined) {
2253
+ if (req.method === 'GET') {
2254
+ const page = readPage(url.searchParams, instance, requestId);
2255
+ if (page instanceof Problem) {
2256
+ send(res, page.status, page.toJSON(), requestId);
2257
+ return;
2258
+ }
2259
+ const { items, nextCursor } = await options.groups.list(auth, page);
2260
+ send(res, 200, { items: items.map(groupJson), next_cursor: nextCursor }, requestId);
2261
+ return;
2262
+ }
2263
+ if (req.method === 'POST') {
2264
+ const name = (body ?? {}).name;
2265
+ if (typeof name !== 'string' || name.trim().length === 0) {
2266
+ const problem = badRequest(instance, requestId, "'name' is required.");
2267
+ send(res, problem.status, problem.toJSON(), requestId);
2268
+ return;
2269
+ }
2270
+ const created = await options.groups.create(auth, name.trim());
2271
+ if (created === undefined) {
2272
+ await options.audit.write({
2273
+ orgId: auth.orgId,
2274
+ actor: `${auth.principal.type}:${auth.principal.id}`,
2275
+ action: 'create_group',
2276
+ result: 'deny',
2277
+ detail: { name: name.trim(), reason: 'name taken' },
2278
+ requestId,
2279
+ });
2280
+ const problem = new Problem({
2281
+ type: 'https://nacre.work/errors/conflict',
2282
+ title: 'Conflict',
2283
+ status: 409,
2284
+ detail: `A group named '${name.trim()}' already exists in this organization.`,
2285
+ instance,
2286
+ requestId,
2287
+ });
2288
+ send(res, problem.status, problem.toJSON(), requestId);
2289
+ return;
2290
+ }
2291
+ await options.audit.write({
2292
+ orgId: auth.orgId,
2293
+ actor: `${auth.principal.type}:${auth.principal.id}`,
2294
+ action: 'create_group',
2295
+ result: 'allow',
2296
+ detail: { group_id: created.id, name: created.name },
2297
+ requestId,
2298
+ });
2299
+ send(res, 201, groupJson(created), requestId);
2300
+ return;
2301
+ }
2302
+ }
2303
+ const groupMatch = /^\/v1\/groups\/([^/]+)$/.exec(instance);
2304
+ if (req.method === 'DELETE' && groupMatch && options.groups !== undefined) {
2305
+ const id = decodeURIComponent(groupMatch[1]);
2306
+ const removed = await options.groups.remove(auth, id);
2307
+ await options.audit.write({
2308
+ orgId: auth.orgId,
2309
+ actor: `${auth.principal.type}:${auth.principal.id}`,
2310
+ action: 'delete_group',
2311
+ result: removed ? 'allow' : 'deny',
2312
+ detail: { group_id: id },
2313
+ requestId,
2314
+ });
2315
+ if (!removed) {
2316
+ const problem = notFound(instance, requestId);
2317
+ send(res, problem.status, problem.toJSON(), requestId);
2318
+ return;
2319
+ }
2320
+ send(res, 204, null, requestId);
2321
+ return;
2322
+ }
2323
+ const membersMatch = /^\/v1\/groups\/([^/]+)\/members$/.exec(instance);
2324
+ if (membersMatch && options.groups !== undefined) {
2325
+ const groupId = decodeURIComponent(membersMatch[1]);
2326
+ if (req.method === 'GET') {
2327
+ const page = readPage(url.searchParams, instance, requestId);
2328
+ if (page instanceof Problem) {
2329
+ send(res, page.status, page.toJSON(), requestId);
2330
+ return;
2331
+ }
2332
+ const result = await options.groups.members(auth, groupId, page);
2333
+ if (result === undefined) {
2334
+ const problem = notFound(instance, requestId);
2335
+ send(res, problem.status, problem.toJSON(), requestId);
2336
+ return;
2337
+ }
2338
+ send(res, 200, { items: result.items.map(memberJson), next_cursor: result.nextCursor }, requestId);
2339
+ return;
2340
+ }
2341
+ if (req.method === 'POST') {
2342
+ const fields = (body ?? {});
2343
+ const type = fields.type;
2344
+ const memberId = fields.id;
2345
+ if ((type !== 'user' && type !== 'group') || typeof memberId !== 'string') {
2346
+ const problem = badRequest(instance, requestId, "'type' must be 'user' or 'group', and 'id' is required.");
2347
+ send(res, problem.status, problem.toJSON(), requestId);
2348
+ return;
2349
+ }
2350
+ const outcome = await options.groups.addMember(auth, groupId, { type, id: memberId });
2351
+ await options.audit.write({
2352
+ orgId: auth.orgId,
2353
+ actor: `${auth.principal.type}:${auth.principal.id}`,
2354
+ action: 'add_group_member',
2355
+ result: outcome === 'no-group' || outcome === 'no-member' ? 'deny' : 'allow',
2356
+ detail: {
2357
+ group_id: groupId,
2358
+ member_type: type,
2359
+ member_id: memberId,
2360
+ ...(outcome === 'already' ? { note: 'already a member' } : {}),
2361
+ ...(outcome === 'no-group' || outcome === 'no-member' ? { reason: outcome } : {}),
2362
+ },
2363
+ requestId,
2364
+ });
2365
+ if (outcome === 'no-group' || outcome === 'no-member') {
2366
+ const problem = notFound(instance, requestId);
2367
+ send(res, problem.status, problem.toJSON(), requestId);
2368
+ return;
2369
+ }
2370
+ // 204 for both `added` and `already`. The request asked for a state and
2371
+ // that state holds either way, and distinguishing them would tell a
2372
+ // caller whether somebody was already in a group — which is a fact
2373
+ // about the group and not about their request.
2374
+ send(res, 204, null, requestId);
2375
+ return;
2376
+ }
2377
+ }
2378
+ // `{type}/{id}` rather than `{id}` alone: the edge is keyed by which member
2379
+ // column it uses, so a bare uuid does not identify one. Same shape `grants`
2380
+ // uses for the other end of the same relationship.
2381
+ const memberMatch = /^\/v1\/groups\/([^/]+)\/members\/(user|group)\/([^/]+)$/.exec(instance);
2382
+ if (req.method === 'DELETE' && memberMatch && options.groups !== undefined) {
2383
+ const groupId = decodeURIComponent(memberMatch[1]);
2384
+ const type = memberMatch[2];
2385
+ const memberId = decodeURIComponent(memberMatch[3]);
2386
+ const removed = await options.groups.removeMember(auth, groupId, { type, id: memberId });
2387
+ await options.audit.write({
2388
+ orgId: auth.orgId,
2389
+ actor: `${auth.principal.type}:${auth.principal.id}`,
2390
+ action: 'remove_group_member',
2391
+ result: removed ? 'allow' : 'deny',
2392
+ detail: { group_id: groupId, member_type: type, member_id: memberId },
2393
+ requestId,
2394
+ });
2395
+ if (!removed) {
2396
+ const problem = notFound(instance, requestId);
2397
+ send(res, problem.status, problem.toJSON(), requestId);
2398
+ return;
2399
+ }
2400
+ send(res, 204, null, requestId);
2401
+ return;
2402
+ }
1925
2403
  const problem = notFound(instance, requestId);
1926
2404
  send(res, problem.status, problem.toJSON(), requestId);
1927
2405
  }