@nacre.work/api 0.33.0 → 0.34.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
@@ -16,6 +16,7 @@ import { looksLikeEmail } from './principals.js';
16
16
  import { clientSource } from './source.js';
17
17
  import { auditFormat, auditJson, readAuditQuery, toCsv, toNdjson, } from './audit-export.js';
18
18
  import { decodeCursor, readPage } from './pagination.js';
19
+ import { applyProposal, cancelProposal } from './proposals.js';
19
20
  /**
20
21
  * The default body cap, in bytes.
21
22
  *
@@ -3155,6 +3156,38 @@ async function handle(req, res, options) {
3155
3156
  }, requestId);
3156
3157
  return;
3157
3158
  }
3159
+ /*
3160
+ * Where a client connects: the REST base, the MCP endpoint, and — for an
3161
+ * organization administrator — the administrative one.
3162
+ *
3163
+ * Asked of the server rather than worked out in a browser, because the
3164
+ * page's own origin is the answer only when the console, the API and the
3165
+ * MCP transport share one, and the operator already told the server which
3166
+ * addresses are the public ones. Any credential may ask: a member connects
3167
+ * their own client, and an agent reading this learns nothing its token did
3168
+ * not already let it reach.
3169
+ *
3170
+ * `mcp_admin` is the administrative MCP, and only somebody who administers
3171
+ * this organization is told it — its consent screen refuses everybody
3172
+ * else, so offering it would be offering a connection that cannot be made.
3173
+ * The same `administers` the gated handlers call.
3174
+ */
3175
+ if (instance === '/v1/endpoints') {
3176
+ if (req.method !== 'GET' || options.endpoints === undefined) {
3177
+ const problem = notFound(instance, requestId);
3178
+ send(res, problem.status, problem.toJSON(), requestId);
3179
+ return;
3180
+ }
3181
+ const e = options.endpoints;
3182
+ send(res, 200, {
3183
+ api: e.api,
3184
+ mcp: e.mcp,
3185
+ ...(administers(auth) ? { mcp_admin: e.mcpAdmin } : {}),
3186
+ contract: e.contract,
3187
+ version: e.version,
3188
+ }, requestId);
3189
+ return;
3190
+ }
3158
3191
  /*
3159
3192
  * The caller's own password.
3160
3193
  *
@@ -4123,6 +4156,76 @@ async function handle(req, res, options) {
4123
4156
  * is a denylist consulted on every request, which would make local
4124
4157
  * verification not local.
4125
4158
  */
4159
+ // ── proposals ─────────────────────────────────────────────────────────
4160
+ //
4161
+ // docs/mcp-admin.md, "A change is proposed, and a person applies it". What
4162
+ // the person's administrative connection proposed, waiting for them, and
4163
+ // the two buttons. A person in their own session and nobody else: every
4164
+ // other caller — a connected application's token included — gets the
4165
+ // answer a path that does not exist gets.
4166
+ const proposalMatch = pathMatch(/^\/v1\/proposals(?:\/([0-9a-f-]{36})\/(apply|cancel))?$/i, instance);
4167
+ if (proposalMatch !== null && options.proposals !== undefined) {
4168
+ const own = auth.principal.type === 'user' && auth.delegation === undefined && administers(auth);
4169
+ const id = proposalMatch[1];
4170
+ const verb = proposalMatch[2];
4171
+ if (!own || (id === undefined ? req.method !== 'GET' : req.method !== 'POST')) {
4172
+ const problem = notFound(instance, requestId);
4173
+ send(res, problem.status, problem.toJSON(), requestId);
4174
+ return;
4175
+ }
4176
+ const deps = { proposals: options.proposals.store, audit: options.audit, writes: options.proposals.writes };
4177
+ const by = { through: 'console', personId: auth.principal.id };
4178
+ if (id === undefined) {
4179
+ const items = await options.proposals.store.pending(auth);
4180
+ send(res, 200, {
4181
+ items: items.map((p) => ({
4182
+ id: p.id,
4183
+ tool: p.tool,
4184
+ module: p.module,
4185
+ summary: p.summary,
4186
+ details: p.details,
4187
+ created_at: p.createdAt,
4188
+ expires_at: p.expiresAt,
4189
+ connection: { id: p.connection.id, application: p.connection.application },
4190
+ })),
4191
+ }, requestId);
4192
+ return;
4193
+ }
4194
+ const outcome = verb === 'apply'
4195
+ ? await applyProposal(deps, auth, id, by, requestId)
4196
+ : await cancelProposal(deps, auth, id, by, requestId);
4197
+ switch (outcome.kind) {
4198
+ case 'applied':
4199
+ send(res, 200, { applied: true, result: outcome.result }, requestId);
4200
+ return;
4201
+ case 'cancelled':
4202
+ send(res, 204, null, requestId);
4203
+ return;
4204
+ case 'refused': {
4205
+ // The proposal was theirs and is now spent; the change itself was
4206
+ // refused, in the tool's own words — the role was lost, the name is
4207
+ // taken, the last administrator would go. Not 404: they are looking
4208
+ // straight at it.
4209
+ const problem = new Problem({
4210
+ type: 'https://nacre.work/errors/proposal-refused',
4211
+ title: 'The change was refused',
4212
+ status: 409,
4213
+ detail: outcome.reason,
4214
+ instance,
4215
+ requestId,
4216
+ });
4217
+ send(res, problem.status, problem.toJSON(), requestId);
4218
+ return;
4219
+ }
4220
+ default: {
4221
+ // Absent, somebody else's, decided, expired, or proposed through a
4222
+ // connection revoked since — one answer.
4223
+ const problem = notFound(instance, requestId);
4224
+ send(res, problem.status, problem.toJSON(), requestId);
4225
+ return;
4226
+ }
4227
+ }
4228
+ }
4126
4229
  if (instance === '/v1/oauth/consents' && options.oauth !== undefined) {
4127
4230
  if (req.method !== 'GET') {
4128
4231
  const problem = notFound(instance, requestId);