@nacre.work/api 0.3.0 → 0.5.0

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,
@@ -964,34 +984,74 @@ async function handle(req, res, options) {
964
984
  ? body_.external_id
965
985
  : (uploaded?.filename ?? undefined);
966
986
  let content = body_.content;
987
+ let binary;
967
988
  if (uploaded !== undefined) {
968
989
  if (typeof content === 'string' || typeof body_.url === 'string') {
969
990
  const problem = badRequest(instance, requestId, "A multipart upload carries the document; 'content' and 'url' are for the JSON body.");
970
991
  send(res, problem.status, problem.toJSON(), requestId);
971
992
  return;
972
993
  }
973
- // Decoded here, and refused here, rather than queued and failed later.
974
- //
975
- // The parser this feeds extracts no binary formats — it is stdlib-only
976
- // on purpose, since it runs hostile input through whatever it depends
977
- // on. Until this check existed the sidecar decoded with
978
- // `errors="replace"`, so a PDF became a string of replacement
979
- // characters that was chunked, embedded, stored as the document body
980
- // and reported as indexed.
981
- //
982
- // At the edge the caller learns immediately and nothing is queued. Deep
983
- // in the worker they would have learned from a `failed` row minutes
984
- // later, if they looked.
985
- const decoder = new TextDecoder('utf-8', { fatal: true });
986
- try {
987
- content = decoder.decode(uploaded.bytes);
994
+ // PDF first, and both signals must agree: the part must declare
995
+ // `application/pdf` AND the bytes must begin with `%PDF-`. Either
996
+ // alone is a refusal that names the other — a declared type the bytes
997
+ // contradict is exactly the disagreement the multipart parser's
998
+ // strictness doctrine exists to refuse, and sniffing alone would turn
999
+ // the declared type into decoration. Other formats extend this table;
1000
+ // nothing falls through to a guess.
1001
+ const declared = (uploaded.contentType ?? '').split(';')[0]?.trim().toLowerCase() ?? '';
1002
+ const MAGIC = [0x25, 0x50, 0x44, 0x46, 0x2d]; // %PDF-
1003
+ const magic = uploaded.bytes.length >= MAGIC.length && MAGIC.every((b, i) => uploaded.bytes[i] === b);
1004
+ if (declared === 'application/pdf' && !magic) {
1005
+ const problem = badRequest(instance, requestId, "The file part declares 'application/pdf' but the bytes do not begin with the %PDF- magic. " +
1006
+ 'Both must agree; a declared type the bytes contradict is refused rather than trusted.');
1007
+ send(res, problem.status, problem.toJSON(), requestId);
1008
+ return;
988
1009
  }
989
- catch {
990
- const problem = badRequest(instance, requestId, 'The uploaded file is not UTF-8 text. This installation extracts no binary formats — ' +
991
- 'a PDF, a Word file or an image needs an extractor the parser deliberately does not carry.');
1010
+ if (declared !== 'application/pdf' && magic) {
1011
+ const problem = badRequest(instance, requestId, "The bytes begin with the %PDF- magic but the file part does not declare 'application/pdf'. " +
1012
+ 'Both must agree; declare the type rather than relying on sniffing.');
992
1013
  send(res, problem.status, problem.toJSON(), requestId);
993
1014
  return;
994
1015
  }
1016
+ if (declared === 'application/pdf' && magic) {
1017
+ // Binary requires object storage, at the edge. The bytes' only home
1018
+ // is the bucket — `documents.source_ref` is text and stays text — so
1019
+ // a deployment without one learns on the request, naming the
1020
+ // variables, not from a `failed` row minutes later.
1021
+ if (options.objectStorage !== true) {
1022
+ const problem = badRequest(instance, requestId, 'A PDF upload needs object storage, and this deployment has none configured. ' +
1023
+ 'Set NACRE_S3_* (endpoint, bucket, access key, secret key) to enable binary ingest.');
1024
+ send(res, problem.status, problem.toJSON(), requestId);
1025
+ return;
1026
+ }
1027
+ binary = { bytes: uploaded.bytes, contentType: 'application/pdf' };
1028
+ }
1029
+ else {
1030
+ // Decoded here, and refused here, rather than queued and failed
1031
+ // later.
1032
+ //
1033
+ // The parser extracts exactly the formats in the table above — it
1034
+ // took its first dependency for PDF and nothing else. Until this
1035
+ // check existed the sidecar decoded with `errors="replace"`, so a
1036
+ // binary file became a string of replacement characters that was
1037
+ // chunked, embedded, stored as the document body and reported as
1038
+ // indexed.
1039
+ //
1040
+ // At the edge the caller learns immediately and nothing is queued.
1041
+ // Deep in the worker they would have learned from a `failed` row
1042
+ // minutes later, if they looked.
1043
+ const decoder = new TextDecoder('utf-8', { fatal: true });
1044
+ try {
1045
+ content = decoder.decode(uploaded.bytes);
1046
+ }
1047
+ catch {
1048
+ const problem = badRequest(instance, requestId, 'The uploaded file is not UTF-8 text. This installation extracts PDF and nothing else — ' +
1049
+ 'a Word file or an image needs an extractor the parser deliberately does not carry, ' +
1050
+ 'and a PDF must declare application/pdf on the file part.');
1051
+ send(res, problem.status, problem.toJSON(), requestId);
1052
+ return;
1053
+ }
1054
+ }
995
1055
  }
996
1056
  const url_ = body_.url;
997
1057
  if (typeof layer !== 'string' || typeof externalId !== 'string') {
@@ -999,7 +1059,10 @@ async function handle(req, res, options) {
999
1059
  send(res, problem.status, problem.toJSON(), requestId);
1000
1060
  return;
1001
1061
  }
1002
- if ((typeof content === 'string') === (typeof url_ === 'string')) {
1062
+ // A PDF file part is the third source and already excludes the other
1063
+ // two: `content` and `url` beside a file were refused above, before the
1064
+ // bytes were even looked at.
1065
+ if (binary === undefined && (typeof content === 'string') === (typeof url_ === 'string')) {
1003
1066
  // Accepting both would mean choosing silently, and the choice would
1004
1067
  // differ from whatever the caller assumed.
1005
1068
  const problem = badRequest(instance, requestId, "Exactly one of 'content' or 'url' is required.");
@@ -1046,6 +1109,7 @@ async function handle(req, res, options) {
1046
1109
  ...(typeof body_.title === 'string' ? { title: body_.title } : {}),
1047
1110
  ...(typeof content === 'string' ? { content } : {}),
1048
1111
  ...(typeof url_ === 'string' ? { url: url_ } : {}),
1112
+ ...(binary === undefined ? {} : { bytes: binary.bytes, contentType: binary.contentType }),
1049
1113
  metadata,
1050
1114
  });
1051
1115
  // The accept stage only — parse, chunk and embed happen in the worker,
@@ -1505,6 +1569,35 @@ async function handle(req, res, options) {
1505
1569
  send(res, 204, null, requestId);
1506
1570
  return;
1507
1571
  }
1572
+ if (req.method === 'DELETE' && layerMatch !== null) {
1573
+ const layerId = layerMatch[1];
1574
+ if (options.layers?.remove === undefined) {
1575
+ const problem = notFound(instance, requestId);
1576
+ send(res, problem.status, problem.toJSON(), requestId);
1577
+ return;
1578
+ }
1579
+ const removed = await options.layers.remove(auth, layerId);
1580
+ // Recorded whichever way it went, and before the answer. Deleting a layer
1581
+ // takes every document in it out of every answer at once, which is the
1582
+ // largest single thing a caller can do here — a refused attempt is worth
1583
+ // as much to an investigation as a successful one.
1584
+ await options.audit.write({
1585
+ orgId: auth.orgId,
1586
+ actor: `${auth.principal.type}:${auth.principal.id}`,
1587
+ action: 'delete_layer',
1588
+ result: removed ? 'allow' : 'deny',
1589
+ target: { layer_id: layerId },
1590
+ detail: { layer_id: layerId },
1591
+ requestId,
1592
+ });
1593
+ if (!removed) {
1594
+ const problem = notFound(instance, requestId);
1595
+ send(res, problem.status, problem.toJSON(), requestId);
1596
+ return;
1597
+ }
1598
+ send(res, 204, null, requestId);
1599
+ return;
1600
+ }
1508
1601
  // `/v1/admin/...` — routes a commercial module mounted.
1509
1602
  //
1510
1603
  // After authentication and after `rejectTenantOverride`, deliberately. A
@@ -1878,6 +1971,389 @@ async function handle(req, res, options) {
1878
1971
  send(res, 204, null, requestId);
1879
1972
  return;
1880
1973
  }
1974
+ // ───────────────────────────── principals ─────────────────────────────
1975
+ //
1976
+ // Users and groups. `org_admin`, on the same argument service accounts
1977
+ // make: a principal belongs to the organization rather than to a scope
1978
+ // inside it, so there is nothing to check `admin` against — and someone
1979
+ // holding admin on one layer must not be able to mint one.
1980
+ //
1981
+ // The refusal writes a `deny` event. It surfaces as `404`, which is what
1982
+ // makes it easy to miss: an early return that answers "no such path" is
1983
+ // still a refusal, and `docs/audit.md` counts every one.
1984
+ const principalPath = /^\/v1\/(users|groups)(\/.*)?$/.exec(instance);
1985
+ if (principalPath !== null) {
1986
+ const port = principalPath[1] === 'users' ? options.users : options.groups;
1987
+ if (port !== undefined && auth.role !== 'org_admin') {
1988
+ await options.audit.write({
1989
+ orgId: auth.orgId,
1990
+ actor: `${auth.principal.type}:${auth.principal.id}`,
1991
+ action: 'administer_principals',
1992
+ result: 'deny',
1993
+ detail: { path: instance, method: req.method ?? 'GET', reason: 'not an org_admin' },
1994
+ requestId,
1995
+ });
1996
+ const problem = notFound(instance, requestId);
1997
+ send(res, problem.status, problem.toJSON(), requestId);
1998
+ return;
1999
+ }
2000
+ }
2001
+ if (instance === '/v1/users' && options.users !== undefined) {
2002
+ if (req.method === 'GET') {
2003
+ const page = readPage(url.searchParams, instance, requestId);
2004
+ if (page instanceof Problem) {
2005
+ send(res, page.status, page.toJSON(), requestId);
2006
+ return;
2007
+ }
2008
+ const { items, nextCursor } = await options.users.list(auth, page);
2009
+ send(res, 200, { items: items.map(userJson), next_cursor: nextCursor }, requestId);
2010
+ return;
2011
+ }
2012
+ if (req.method === 'POST') {
2013
+ const fields = (body ?? {});
2014
+ const email = typeof fields.email === 'string' ? fields.email.trim() : '';
2015
+ if (!looksLikeEmail(email)) {
2016
+ const problem = badRequest(instance, requestId, "'email' is required and must be an address.");
2017
+ send(res, problem.status, problem.toJSON(), requestId);
2018
+ return;
2019
+ }
2020
+ const role = fields.role ?? 'member';
2021
+ // `platform_admin` is deliberately not creatable here, and this is the
2022
+ // one refusal in this block that is about the model rather than about
2023
+ // input. That role administers the *installation* and spans tenants in
2024
+ // the multi-tenancy module; an org_admin minting one would be
2025
+ // escalating out of their own organization through an endpoint scoped
2026
+ // to it. It is set by whoever runs `init`, and stays there.
2027
+ if (role !== 'member' && role !== 'org_admin') {
2028
+ const problem = badRequest(instance, requestId, "'role' must be 'member' or 'org_admin'. 'platform_admin' administers the " +
2029
+ 'installation rather than this organization and is not issued here.');
2030
+ send(res, problem.status, problem.toJSON(), requestId);
2031
+ return;
2032
+ }
2033
+ const created = await options.users.create(auth, email, role);
2034
+ if (created === undefined) {
2035
+ await options.audit.write({
2036
+ orgId: auth.orgId,
2037
+ actor: `${auth.principal.type}:${auth.principal.id}`,
2038
+ action: 'create_user',
2039
+ result: 'deny',
2040
+ detail: { email, reason: 'address taken' },
2041
+ requestId,
2042
+ });
2043
+ const problem = new Problem({
2044
+ type: 'https://nacre.work/errors/conflict',
2045
+ title: 'Conflict',
2046
+ status: 409,
2047
+ detail: `A user with the address '${email}' already exists in this organization.`,
2048
+ instance,
2049
+ requestId,
2050
+ });
2051
+ send(res, problem.status, problem.toJSON(), requestId);
2052
+ return;
2053
+ }
2054
+ await options.audit.write({
2055
+ orgId: auth.orgId,
2056
+ actor: `${auth.principal.type}:${auth.principal.id}`,
2057
+ action: 'create_user',
2058
+ result: 'allow',
2059
+ // The address and the role. Never the password — this row is readable
2060
+ // by anyone with the audit log, and the password is not recoverable
2061
+ // from anywhere else by design.
2062
+ detail: { user_id: created.user.id, email, role },
2063
+ requestId,
2064
+ });
2065
+ // The only time the password exists outside the caller's process.
2066
+ send(res, 201, { ...userJson(created.user), password: created.password }, requestId);
2067
+ return;
2068
+ }
2069
+ }
2070
+ const userMatch = /^\/v1\/users\/([^/]+)$/.exec(instance);
2071
+ if (userMatch && options.users !== undefined) {
2072
+ const id = decodeURIComponent(userMatch[1]);
2073
+ if (req.method === 'DELETE') {
2074
+ // Disabled, never deleted, which is what `DELETE` means on every
2075
+ // removable thing here: a document is tombstoned, a key is revoked, and
2076
+ // a user keeps their row because the audit log names its id and
2077
+ // `grants.created_by` references it with no cascade. `PATCH` with
2078
+ // `disabled: false` is how somebody comes back.
2079
+ //
2080
+ // Through the same call `PATCH` makes rather than a second statement,
2081
+ // so the last-administrator guard covers both spellings. Two removals
2082
+ // with one check between them is how the guarded one gets routed
2083
+ // around.
2084
+ const disabled = await options.users.update(auth, id, { disabled: true });
2085
+ await options.audit.write({
2086
+ orgId: auth.orgId,
2087
+ actor: `${auth.principal.type}:${auth.principal.id}`,
2088
+ action: 'disable_user',
2089
+ result: disabled === 'updated' ? 'allow' : 'deny',
2090
+ detail: { user_id: id, ...(disabled === 'updated' ? {} : { reason: disabled }) },
2091
+ requestId,
2092
+ });
2093
+ if (disabled === 'last-admin') {
2094
+ const problem = new Problem({
2095
+ type: 'https://nacre.work/errors/conflict',
2096
+ title: 'Conflict',
2097
+ status: 409,
2098
+ detail: 'This is the only active org_admin in the organization. Promote another user ' +
2099
+ 'first — an organization with none has no route back through the API.',
2100
+ instance,
2101
+ requestId,
2102
+ });
2103
+ send(res, problem.status, problem.toJSON(), requestId);
2104
+ return;
2105
+ }
2106
+ if (disabled === 'no-user') {
2107
+ const problem = notFound(instance, requestId);
2108
+ send(res, problem.status, problem.toJSON(), requestId);
2109
+ return;
2110
+ }
2111
+ send(res, 204, null, requestId);
2112
+ return;
2113
+ }
2114
+ if (req.method === 'PATCH') {
2115
+ const fields = (body ?? {});
2116
+ const wantsRole = 'role' in fields;
2117
+ const wantsDisabled = 'disabled' in fields;
2118
+ if (!wantsRole && !wantsDisabled) {
2119
+ const problem = badRequest(instance, requestId, "Give at least one of 'role' or 'disabled'.");
2120
+ send(res, problem.status, problem.toJSON(), requestId);
2121
+ return;
2122
+ }
2123
+ if (wantsRole && fields.role !== 'member' && fields.role !== 'org_admin') {
2124
+ const problem = badRequest(instance, requestId, "'role' must be 'member' or 'org_admin'. 'platform_admin' administers the " +
2125
+ 'installation rather than this organization and is not issued here.');
2126
+ send(res, problem.status, problem.toJSON(), requestId);
2127
+ return;
2128
+ }
2129
+ if (wantsDisabled && typeof fields.disabled !== 'boolean') {
2130
+ const problem = badRequest(instance, requestId, "'disabled' must be a boolean.");
2131
+ send(res, problem.status, problem.toJSON(), requestId);
2132
+ return;
2133
+ }
2134
+ // The last administrator cannot demote or disable themselves through
2135
+ // this path. Not paternalism: an organization with no `org_admin` has
2136
+ // no route back — every endpoint that could restore one is behind the
2137
+ // role that was just given up, and the remedy would be SQL. The check
2138
+ // is in the adapter, where it can count in the same transaction.
2139
+ const changed = await options.users.update(auth, id, {
2140
+ ...(wantsRole ? { role: fields.role } : {}),
2141
+ ...(wantsDisabled ? { disabled: fields.disabled } : {}),
2142
+ });
2143
+ await options.audit.write({
2144
+ orgId: auth.orgId,
2145
+ actor: `${auth.principal.type}:${auth.principal.id}`,
2146
+ action: 'update_user',
2147
+ result: changed === 'updated' ? 'allow' : 'deny',
2148
+ detail: {
2149
+ user_id: id,
2150
+ ...(wantsRole ? { role: fields.role } : {}),
2151
+ ...(wantsDisabled ? { disabled: fields.disabled } : {}),
2152
+ ...(changed === 'updated' ? {} : { reason: changed }),
2153
+ },
2154
+ requestId,
2155
+ });
2156
+ if (changed === 'last-admin') {
2157
+ // 409 rather than 404: the caller is looking straight at this user —
2158
+ // it is their own account or one they just listed — so the answer is
2159
+ // about the organization's state and not about what they can see.
2160
+ // Invariant 4 is about invisibility, and nothing here is invisible.
2161
+ const problem = new Problem({
2162
+ type: 'https://nacre.work/errors/conflict',
2163
+ title: 'Conflict',
2164
+ status: 409,
2165
+ detail: 'This is the only active org_admin in the organization. Promote another user ' +
2166
+ 'first — an organization with none has no route back through the API.',
2167
+ instance,
2168
+ requestId,
2169
+ });
2170
+ send(res, problem.status, problem.toJSON(), requestId);
2171
+ return;
2172
+ }
2173
+ if (changed === 'no-user') {
2174
+ const problem = notFound(instance, requestId);
2175
+ send(res, problem.status, problem.toJSON(), requestId);
2176
+ return;
2177
+ }
2178
+ send(res, 204, null, requestId);
2179
+ return;
2180
+ }
2181
+ }
2182
+ const passwordMatch = /^\/v1\/users\/([^/]+)\/password$/.exec(instance);
2183
+ if (req.method === 'POST' && passwordMatch && options.users !== undefined) {
2184
+ const id = decodeURIComponent(passwordMatch[1]);
2185
+ const password = await options.users.resetPassword(auth, id);
2186
+ await options.audit.write({
2187
+ orgId: auth.orgId,
2188
+ actor: `${auth.principal.type}:${auth.principal.id}`,
2189
+ action: 'reset_password',
2190
+ result: password === undefined ? 'deny' : 'allow',
2191
+ // That it happened and to whom. Never the value.
2192
+ detail: { user_id: id },
2193
+ requestId,
2194
+ });
2195
+ if (password === undefined) {
2196
+ const problem = notFound(instance, requestId);
2197
+ send(res, problem.status, problem.toJSON(), requestId);
2198
+ return;
2199
+ }
2200
+ // Generated rather than chosen, for the reason `init` gives: an argument
2201
+ // ends up in a shell history, and a password an administrator picked is
2202
+ // one they know. This is the only time it exists outside the process.
2203
+ send(res, 200, { password }, requestId);
2204
+ return;
2205
+ }
2206
+ if (instance === '/v1/groups' && options.groups !== undefined) {
2207
+ if (req.method === 'GET') {
2208
+ const page = readPage(url.searchParams, instance, requestId);
2209
+ if (page instanceof Problem) {
2210
+ send(res, page.status, page.toJSON(), requestId);
2211
+ return;
2212
+ }
2213
+ const { items, nextCursor } = await options.groups.list(auth, page);
2214
+ send(res, 200, { items: items.map(groupJson), next_cursor: nextCursor }, requestId);
2215
+ return;
2216
+ }
2217
+ if (req.method === 'POST') {
2218
+ const name = (body ?? {}).name;
2219
+ if (typeof name !== 'string' || name.trim().length === 0) {
2220
+ const problem = badRequest(instance, requestId, "'name' is required.");
2221
+ send(res, problem.status, problem.toJSON(), requestId);
2222
+ return;
2223
+ }
2224
+ const created = await options.groups.create(auth, name.trim());
2225
+ if (created === undefined) {
2226
+ await options.audit.write({
2227
+ orgId: auth.orgId,
2228
+ actor: `${auth.principal.type}:${auth.principal.id}`,
2229
+ action: 'create_group',
2230
+ result: 'deny',
2231
+ detail: { name: name.trim(), reason: 'name taken' },
2232
+ requestId,
2233
+ });
2234
+ const problem = new Problem({
2235
+ type: 'https://nacre.work/errors/conflict',
2236
+ title: 'Conflict',
2237
+ status: 409,
2238
+ detail: `A group named '${name.trim()}' already exists in this organization.`,
2239
+ instance,
2240
+ requestId,
2241
+ });
2242
+ send(res, problem.status, problem.toJSON(), requestId);
2243
+ return;
2244
+ }
2245
+ await options.audit.write({
2246
+ orgId: auth.orgId,
2247
+ actor: `${auth.principal.type}:${auth.principal.id}`,
2248
+ action: 'create_group',
2249
+ result: 'allow',
2250
+ detail: { group_id: created.id, name: created.name },
2251
+ requestId,
2252
+ });
2253
+ send(res, 201, groupJson(created), requestId);
2254
+ return;
2255
+ }
2256
+ }
2257
+ const groupMatch = /^\/v1\/groups\/([^/]+)$/.exec(instance);
2258
+ if (req.method === 'DELETE' && groupMatch && options.groups !== undefined) {
2259
+ const id = decodeURIComponent(groupMatch[1]);
2260
+ const removed = await options.groups.remove(auth, id);
2261
+ await options.audit.write({
2262
+ orgId: auth.orgId,
2263
+ actor: `${auth.principal.type}:${auth.principal.id}`,
2264
+ action: 'delete_group',
2265
+ result: removed ? 'allow' : 'deny',
2266
+ detail: { group_id: id },
2267
+ requestId,
2268
+ });
2269
+ if (!removed) {
2270
+ const problem = notFound(instance, requestId);
2271
+ send(res, problem.status, problem.toJSON(), requestId);
2272
+ return;
2273
+ }
2274
+ send(res, 204, null, requestId);
2275
+ return;
2276
+ }
2277
+ const membersMatch = /^\/v1\/groups\/([^/]+)\/members$/.exec(instance);
2278
+ if (membersMatch && options.groups !== undefined) {
2279
+ const groupId = decodeURIComponent(membersMatch[1]);
2280
+ if (req.method === 'GET') {
2281
+ const page = readPage(url.searchParams, instance, requestId);
2282
+ if (page instanceof Problem) {
2283
+ send(res, page.status, page.toJSON(), requestId);
2284
+ return;
2285
+ }
2286
+ const result = await options.groups.members(auth, groupId, page);
2287
+ if (result === undefined) {
2288
+ const problem = notFound(instance, requestId);
2289
+ send(res, problem.status, problem.toJSON(), requestId);
2290
+ return;
2291
+ }
2292
+ send(res, 200, { items: result.items.map(memberJson), next_cursor: result.nextCursor }, requestId);
2293
+ return;
2294
+ }
2295
+ if (req.method === 'POST') {
2296
+ const fields = (body ?? {});
2297
+ const type = fields.type;
2298
+ const memberId = fields.id;
2299
+ if ((type !== 'user' && type !== 'group') || typeof memberId !== 'string') {
2300
+ const problem = badRequest(instance, requestId, "'type' must be 'user' or 'group', and 'id' is required.");
2301
+ send(res, problem.status, problem.toJSON(), requestId);
2302
+ return;
2303
+ }
2304
+ const outcome = await options.groups.addMember(auth, groupId, { type, id: memberId });
2305
+ await options.audit.write({
2306
+ orgId: auth.orgId,
2307
+ actor: `${auth.principal.type}:${auth.principal.id}`,
2308
+ action: 'add_group_member',
2309
+ result: outcome === 'no-group' || outcome === 'no-member' ? 'deny' : 'allow',
2310
+ detail: {
2311
+ group_id: groupId,
2312
+ member_type: type,
2313
+ member_id: memberId,
2314
+ ...(outcome === 'already' ? { note: 'already a member' } : {}),
2315
+ ...(outcome === 'no-group' || outcome === 'no-member' ? { reason: outcome } : {}),
2316
+ },
2317
+ requestId,
2318
+ });
2319
+ if (outcome === 'no-group' || outcome === 'no-member') {
2320
+ const problem = notFound(instance, requestId);
2321
+ send(res, problem.status, problem.toJSON(), requestId);
2322
+ return;
2323
+ }
2324
+ // 204 for both `added` and `already`. The request asked for a state and
2325
+ // that state holds either way, and distinguishing them would tell a
2326
+ // caller whether somebody was already in a group — which is a fact
2327
+ // about the group and not about their request.
2328
+ send(res, 204, null, requestId);
2329
+ return;
2330
+ }
2331
+ }
2332
+ // `{type}/{id}` rather than `{id}` alone: the edge is keyed by which member
2333
+ // column it uses, so a bare uuid does not identify one. Same shape `grants`
2334
+ // uses for the other end of the same relationship.
2335
+ const memberMatch = /^\/v1\/groups\/([^/]+)\/members\/(user|group)\/([^/]+)$/.exec(instance);
2336
+ if (req.method === 'DELETE' && memberMatch && options.groups !== undefined) {
2337
+ const groupId = decodeURIComponent(memberMatch[1]);
2338
+ const type = memberMatch[2];
2339
+ const memberId = decodeURIComponent(memberMatch[3]);
2340
+ const removed = await options.groups.removeMember(auth, groupId, { type, id: memberId });
2341
+ await options.audit.write({
2342
+ orgId: auth.orgId,
2343
+ actor: `${auth.principal.type}:${auth.principal.id}`,
2344
+ action: 'remove_group_member',
2345
+ result: removed ? 'allow' : 'deny',
2346
+ detail: { group_id: groupId, member_type: type, member_id: memberId },
2347
+ requestId,
2348
+ });
2349
+ if (!removed) {
2350
+ const problem = notFound(instance, requestId);
2351
+ send(res, problem.status, problem.toJSON(), requestId);
2352
+ return;
2353
+ }
2354
+ send(res, 204, null, requestId);
2355
+ return;
2356
+ }
1881
2357
  const problem = notFound(instance, requestId);
1882
2358
  send(res, problem.status, problem.toJSON(), requestId);
1883
2359
  }