@groundfloorcloud/mcp 0.2.3 → 0.2.5

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.
Files changed (3) hide show
  1. package/README.md +1 -1
  2. package/dist/index.js +270 -21
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -82,7 +82,7 @@ Use the prefixed names: `GROUNDFLOOR_API_URL`, `GROUNDFLOOR_WORKSPACE_ID`, `GROU
82
82
  | `secrets_list` / `files_list` / `dataplane_status` | Inspect (no secrets values) |
83
83
  | `domains_list` | Custom domains of an App's Coderunner (`app_id` or `coderunner_id`). Domains exist on Apps, not standalone Coderunners |
84
84
  | `quota_get` / `billing_summary` | Plan quota and billing for the workspace's **account** (account id is not the workspace id; billing needs organization Owner, Admin or Billing admin) |
85
- | `docs_list` / `docs_get` | Product docs (`coderunner`, `coderunner-runtime`, `shell`, `design-standard`, `css-and-styling`, `databases`, `vault`, `feature-flags`, `authentication` are embedded; `access` and `m2m` are live pages, with an `offset` parameter to read past the 12,000-character page limit) |
85
+ | `docs_list` / `docs_get` | Product docs (`coderunner`, `coderunner-runtime`, `shell`, `design-standard`, `css-and-styling`, `databases`, `vault`, `feature-flags`, `authentication`, `service-access` are embedded; `access` and `m2m` are live pages, with an `offset` parameter to read past the 12,000-character page limit) |
86
86
  | `suggest_cli` | Command to run: login, deploy, create_app, init_app, publish_app, m2m_token, … |
87
87
 
88
88
  Never create `app_kind=coderunner`. Deploy with `gf deploy`.
package/dist/index.js CHANGED
@@ -179,12 +179,14 @@ async function getValidAccessToken() {
179
179
 
180
180
  // ../cli/src/cp.ts
181
181
  var CpError = class extends Error {
182
- constructor(status, message) {
182
+ constructor(status, message, reason = null) {
183
183
  super(message);
184
184
  this.status = status;
185
+ this.reason = reason;
185
186
  this.name = "CpError";
186
187
  }
187
188
  status;
189
+ reason;
188
190
  };
189
191
  function formatCpErrorDetail(detail, fallback) {
190
192
  if (typeof detail === "string" && detail.trim()) return detail.trim();
@@ -204,6 +206,40 @@ function formatCpErrorDetail(detail, fallback) {
204
206
  }
205
207
  return fallback;
206
208
  }
209
+ function parseRetryAfter(value, now = Date.now()) {
210
+ if (!value) return null;
211
+ const trimmed = value.trim();
212
+ if (/^\d+$/.test(trimmed)) return Number(trimmed);
213
+ const at = Date.parse(trimmed);
214
+ if (Number.isNaN(at)) return null;
215
+ return Math.max(0, Math.ceil((at - now) / 1e3));
216
+ }
217
+ function formatWait(seconds) {
218
+ if (seconds < 60) return `${seconds} second${seconds === 1 ? "" : "s"}`;
219
+ const minutes = Math.ceil(seconds / 60);
220
+ if (minutes < 60) return `${minutes} minute${minutes === 1 ? "" : "s"}`;
221
+ const hours = Math.ceil(minutes / 60);
222
+ return `${hours} hour${hours === 1 ? "" : "s"}`;
223
+ }
224
+ var REASON_HEADER = "x-groundfloor-reason";
225
+ function formatCpErrorMessage(status, detail, retryAfterSeconds, reason = null) {
226
+ let message = `${status} ${detail}`;
227
+ const wait = retryAfterSeconds !== null ? `try again in ${formatWait(retryAfterSeconds)}` : "try again later";
228
+ if (status === 429 && reason === "sending_limited") {
229
+ message += ` (workspace sign-in email limit reached: ${wait}, or give access with a copy-link or temporary password instead)`;
230
+ } else if (status === 429) {
231
+ message += ` (rate limited: ${wait})`;
232
+ } else if (status === 423 && reason === "sending_paused") {
233
+ message += " (sign-in email is paused: only Groundfloor support can resume it; meanwhile give access with a copy-link or temporary password, or switch to your own email server)";
234
+ } else if (status === 423 && !/support/i.test(detail)) {
235
+ message += " (locked: Groundfloor has paused or frozen this; contact Groundfloor support)";
236
+ }
237
+ return message;
238
+ }
239
+ function reasonHeader(res) {
240
+ const value = res.headers?.get?.(REASON_HEADER);
241
+ return value ? value.trim() : null;
242
+ }
207
243
  async function parseError(res) {
208
244
  let detail = res.statusText;
209
245
  try {
@@ -213,7 +249,12 @@ async function parseError(res) {
213
249
  }
214
250
  } catch {
215
251
  }
216
- return `${res.status} ${detail}`;
252
+ return formatCpErrorMessage(
253
+ res.status,
254
+ detail,
255
+ parseRetryAfter(res.headers?.get?.("retry-after")),
256
+ reasonHeader(res)
257
+ );
217
258
  }
218
259
  async function cpGet(apiUrl, path2) {
219
260
  const token = await getValidAccessToken();
@@ -224,7 +265,7 @@ async function cpGet(apiUrl, path2) {
224
265
  }
225
266
  });
226
267
  if (!res.ok) {
227
- throw new CpError(res.status, await parseError(res));
268
+ throw new CpError(res.status, await parseError(res), reasonHeader(res));
228
269
  }
229
270
  return await res.json();
230
271
  }
@@ -235,7 +276,7 @@ async function cpDelete(apiUrl, path2) {
235
276
  headers: { Authorization: `Bearer ${token}`, Accept: "application/json" }
236
277
  });
237
278
  if (!res.ok) {
238
- throw new CpError(res.status, await parseError(res));
279
+ throw new CpError(res.status, await parseError(res), reasonHeader(res));
239
280
  }
240
281
  }
241
282
  function queryString(params) {
@@ -272,7 +313,7 @@ async function getDeployment(apiUrl, workspaceId, coderunnerId) {
272
313
  );
273
314
  if (res.status === 204) return null;
274
315
  if (!res.ok) {
275
- throw new CpError(res.status, await parseError(res));
316
+ throw new CpError(res.status, await parseError(res), reasonHeader(res));
276
317
  }
277
318
  return await res.json();
278
319
  }
@@ -483,7 +524,16 @@ async function cpGetJson(apiUrl, token, path2) {
483
524
  }
484
525
  } catch {
485
526
  }
486
- throw new CpError(res.status, `${res.status} ${detail}`);
527
+ throw new CpError(
528
+ res.status,
529
+ formatCpErrorMessage(
530
+ res.status,
531
+ detail,
532
+ parseRetryAfter(res.headers?.get?.("retry-after")),
533
+ reasonHeader(res)
534
+ ),
535
+ reasonHeader(res)
536
+ );
487
537
  }
488
538
  return await res.json();
489
539
  }
@@ -552,7 +602,13 @@ the provider.
552
602
  port 25, 465, 587 or 2525), then **Send test email** (goes to their own
553
603
  verified address). API: \`PUT /v1/workspaces/{id}/auth\` with
554
604
  \`mail: {mode: "custom", ...}\`; test with
555
- \`POST /v1/workspaces/{id}/auth/mail/test\`.
605
+ \`POST /v1/workspaces/{id}/auth/mail/test\`. Over the sending limit it
606
+ returns 429 with \`Retry-After\`; while Groundfloor email is paused, 423. If
607
+ your own address is suppressed, or sending could not be checked, it returns
608
+ 200 with \`ok: false\` and a sentence in \`error\`. These answers carry an
609
+ \`X-Groundfloor-Reason\` header (\`sending_limited\`, \`sending_paused\`,
610
+ \`recipient_suppressed\`, \`send_failed\`); the per-user rate limit's 429
611
+ does not.
556
612
  - A failing custom server does not fall back to Groundfloor email; the send
557
613
  fails. Changing host or port needs the password again.
558
614
  - **Warning:** saving a \`mail\` block with no \`host\` (or
@@ -569,7 +625,12 @@ reset responses, and every user in \`GET /v1/workspaces/{id}/users\`, carry:
569
625
  - \`email_skipped_reason\`: \`smtp_not_configured\` (no email server set up for
570
626
  the workspace's sign-in email), \`send_failed\` (the server refused or
571
627
  failed), \`delivery_unconfirmed\` (timed out; it may still arrive),
572
- \`not_requested\` (sent with \`send_email: false\`), or \`null\`.
628
+ \`not_requested\` (sent with \`send_email: false\`), \`sending_limited\` (the
629
+ workspace or this address reached the sending limit; try later),
630
+ \`sending_paused\` (Groundfloor paused the workspace's sign-in email),
631
+ \`recipient_suppressed\` (the address bounced or reported spam), or \`null\`.
632
+ See "Email sending limits" below. Team invite responses carry the same
633
+ two fields.
573
634
  \`keycloak_smtp_missing\` is retired and no longer returned.
574
635
  - \`email_attempted_at\` (\`null\` = invited before delivery was recorded),
575
636
  \`invite_pending\`, \`password_change_required\`,
@@ -582,7 +643,10 @@ app-user tokens get 403):
582
643
 
583
644
  1. **Resend** (\`POST \u2026/users/{user_id}/resend-invite\`, pending users only):
584
645
  at most once per 60 s and 5 times per 24 h; re-inviting a pending user
585
- counts against the same limit (429 with \`Retry-After\`). If the reason is
646
+ counts against the same limit (429 with \`Retry-After\`). Over the sending
647
+ limit resend also returns 429 with \`Retry-After\` (seconds), told apart by
648
+ the header \`X-Groundfloor-Reason: sending_limited\`: wait that long. When sending is paused or the address is suppressed it returns 200
649
+ with \`email_sent: false\` and the reason. If the reason is
586
650
  \`send_failed\` or \`smtp_not_configured\`, fix email first (Branding \u2192
587
651
  Email sending, Send test email) or resending will fail the same way.
588
652
  2. **Temp password** (\`POST \u2026/users/{user_id}/temporary-password\`, pending
@@ -601,6 +665,40 @@ person who asked for them, tell them to share privately, and never write them
601
665
  to files, commits, logs or chat with anyone else. Once a user has set their
602
666
  own password, none of these apply (409): use reset-password.
603
667
 
668
+ ## Email sending limits
669
+
670
+ Workspace sign-in email (invite, resend, added-user and reset emails, the test
671
+ email) is limited per workspace to 200 per hour and 1,000 per 24 hours, and per
672
+ address to 5 per hour and 10 per 24 hours (rolling windows; Groundfloor can set
673
+ other workspace limits). Limits apply with Groundfloor email and your own
674
+ server. Portal Team invites are not limited.
675
+
676
+ - **Limited** (\`sending_limited\`, or 429 with \`Retry-After\` and
677
+ \`X-Groundfloor-Reason: sending_limited\`): invite and add
678
+ still create the user, without email. Wait and resend, or give access now
679
+ with **Copy link** or **Temp password**.
680
+ - **Paused** (\`sending_paused\`, test email 423): Groundfloor stopped the
681
+ workspace's Groundfloor email after too many bounces or spam reports. Only
682
+ Groundfloor support can resume it. Meanwhile use Copy link or Temp password,
683
+ or switch to **Your own email server** (pause applies to Groundfloor email
684
+ only).
685
+ - **Suppressed** (\`recipient_suppressed\`): the address bounced or reported
686
+ spam, so Groundfloor email will not send to it. Check the address with the
687
+ user. A workspace Admin or Owner can list suppressed addresses (masked) with
688
+ \`GET /v1/workspaces/{id}/mail/suppressions\` and clear one with
689
+ \`DELETE \u2026/mail/suppressions/{suppression_id}\` (optional body
690
+ \`{"email": "<full address>"}\` to clear it from the mail provider's bounce
691
+ and block lists too; the response's \`provider\` says what happened). An
692
+ address that reported spam (\`provider: spam_report_kept\`) stays on the
693
+ provider's spam list until Groundfloor support removes it, so mail to it may
694
+ still be dropped. If the test email says your own address is suppressed,
695
+ clear it the same way. Only clear an address the user confirms is correct
696
+ and wants mail.
697
+ - \`GET /v1/workspaces/{id}/mail/status\` (Admin or Owner) shows the mode,
698
+ \`state\` (\`active\` / \`paused\`), the limits, how many were sent in the
699
+ last hour and 24 hours, and the number of suppressed addresses.
700
+ \`workspace_context\` includes it as \`sign_in_email\`.
701
+
604
702
  ## Multi-tenant (customer companies inside one workspace)
605
703
 
606
704
  Turn on **Multi-tenant** in Settings to get a Tenants tab: one tenant per
@@ -627,8 +725,12 @@ with no role at all gets 403 too.
627
725
  ## In a Shell app
628
726
 
629
727
  The signed-in user's JWT comes from \`window.__GF_GET_ACCESS_TOKEN__()\`. See
630
- \`docs_get\` id \`shell\`. Unattended workloads use M2M instead (\`docs_get\` id
631
- \`m2m\`), not an end-user login.
728
+ \`docs_get\` id \`shell\`. Roles are in the token's \`realm_access.roles\`
729
+ (Groundfloor mode). A Shell app's backend Service is public and must verify
730
+ that token itself (issuer and client ID from \`/v1/public/workspaces/by-host\`,
731
+ JWKS, RS256): \`docs_get\` id \`service-access\`.
732
+ Unattended workloads use M2M instead (\`docs_get\` id \`m2m\`), not an
733
+ end-user login.
632
734
  `.trim();
633
735
 
634
736
  // src/coderunner-runtime.ts
@@ -868,6 +970,11 @@ CMD ["npm", "start"]
868
970
 
869
971
  - Services follow the \`npm start\` / \`PORT\` contract: Node needs \`scripts.start\` if CMD is \`npm start\`.
870
972
  - Prefer curling the Deployment URL (\`/health\`) over \`\u2026/run\`.
973
+ - **The Deployment URL (and any App domain on it) is public.** Groundfloor
974
+ checks no identity. A Service that is a Shell app's backend must verify the
975
+ user's token itself, and allow the Shell origin for CORS. Off-the-shelf
976
+ images (port 80 defaults, MySQL, local uploads, sessions): \`docs_get\` id
977
+ \`service-access\`.
871
978
 
872
979
  ## Latest deploy rules (all types)
873
980
 
@@ -965,10 +1072,14 @@ An M2M client can also read secret **values** directly at runtime instead
965
1072
  of (or alongside) \`--secret\` deploy-time injection: grant it the
966
1073
  **\`secrets:read\`** scope (same Portal **Administer \u2192 M2M tokens** screen,
967
1074
  per-client toggle), then call
968
- \`GET {CONTROLPLANE_URL}/v1/workspaces/{workspace_id}/secrets/{key}\` with
969
- \`Authorization: Bearer\` from \`gf m2m token\` locally, or the deployed
1075
+ \`GET {CONTROLPLANE_URL}/v1/workspaces/{workspace_id}/secrets/{key}?environment_id=$GROUNDFLOOR_ENVIRONMENT_ID\`
1076
+ with \`Authorization: Bearer\` from \`gf m2m token\` locally, or the deployed
970
1077
  workload's injected \`GROUNDFLOOR_M2M_CLIENT_ID\`/\`GROUNDFLOOR_M2M_CLIENT_SECRET\`
971
- exchanged the same way. This path is:
1078
+ exchanged the same way. **Always pass \`environment_id\`**
1079
+ (\`GROUNDFLOOR_ENVIRONMENT_ID\` is injected on deploy for a Coderunner bound to
1080
+ an environment) to choose which environment's value is read: omitted or
1081
+ empty, it resolves to the workspace's \`dev\` environment (or its only active
1082
+ one), so a prod workload silently reads dev secrets. This path is:
972
1083
 
973
1084
  - **Read-only** \u2014 \`secrets:read\` never grants \`upsert\`/\`delete\`, regardless
974
1085
  of the client's writer relation on Vault/Files.
@@ -977,7 +1088,9 @@ exchanged the same way. This path is:
977
1088
  - **Workspace-wide, not per-key or per-environment** \u2014 granting the scope
978
1089
  exposes every secret in the workspace across all environments to that
979
1090
  client's code, same class of trade as the existing Vault/Files writer
980
- grant. Not a fit for isolating one secret to one Coderunner.
1091
+ grant. \`environment_id\` only selects which environment's value is
1092
+ returned; it is not an access boundary. Not a fit for isolating one secret
1093
+ (or one environment) to one Coderunner.
981
1094
 
982
1095
  Prefer it over \`--secret\` injection when you want live rotation and can
983
1096
  do the fetch yourself; keep using \`--secret\` injection for SDKs that only
@@ -1258,6 +1371,113 @@ active environments \xB7 400 bad or reserved key \xB7 422 value/value_type misma
1258
1371
  423 workspace frozen (writes).
1259
1372
  `.trim();
1260
1373
 
1374
+ // src/service-access-guide.ts
1375
+ var SERVICE_ACCESS_GUIDE = `
1376
+ # Who can reach a Service (and checking Shell tokens in a backend)
1377
+
1378
+ ## The rule
1379
+
1380
+ Every Coderunner Service is public. Its Deployment URL, and an App's domain
1381
+ routed to it, accept requests from anyone. Groundfloor routes the request but
1382
+ signs nobody in and checks no token. The Service decides who gets in.
1383
+
1384
+ | Service is\u2026 | Example | Do |
1385
+ |---|---|---|
1386
+ | Open to everyone | website, docs, public API, webhook receiver | Nothing (default) |
1387
+ | An app with its own logins | WordPress, Ghost, Metabase | Nothing from Groundfloor; its own login applies. Admin pages (\`/wp-admin\`) are public too |
1388
+ | Backend for a Shell app | API the Shell app calls with \`__GF_GET_ACCESS_TOKEN__()\` | Verify the user's token on **every** request |
1389
+
1390
+ Never tell a user that Shell sign-in, App authentication or Site access
1391
+ protects a Service. Anyone can call the Service directly.
1392
+
1393
+ ## Verifying a Shell user's token in a backend
1394
+
1395
+ 1. **Issuer and client ID**: \`GET {CONTROLPLANE_URL}/v1/public/workspaces/by-host?host=<shell-site-host>\`
1396
+ (unauthenticated) \u2192 \`{ workspace_id, mode, oidc: { issuer, client_id } | null, site_host }\`.
1397
+ Check \`workspace_id\` is the user's workspace. \`oidc\` is null in mode
1398
+ \`none\`. Groundfloor mode: per-workspace issuer, \`client_id\` \`shell\`.
1399
+ External mode: the provider's issuer and the client ID the customer
1400
+ entered. Look it up **once at setup**, never at runtime from the request's
1401
+ \`Host\` header (caller-controlled). Do not hard-code the issuer host (it
1402
+ differs per cell); copy the issuer **exactly, including any trailing \`/\`**
1403
+ (do not strip it before comparing \`iss\`) into non-secret env such as
1404
+ \`AUTH_ISSUER\` / \`AUTH_CLIENT_ID\` (Dockerfile \`ENV\`, since deploy-time
1405
+ env is not kept between deploys).
1406
+ 2. **Checks** (401 if any fails): RS256 signature against
1407
+ \`{issuer}/protocol/openid-connect/certs\` (External: the provider's
1408
+ \`jwks_uri\`), key chosen by the token's \`kid\` (the JWKS has several
1409
+ keys); \`iss\` equals the issuer exactly; \`exp\` present and in the future;
1410
+ the token was issued to the client ID:
1411
+ - Groundfloor mode: \`azp\` == client ID. Do not require an app-specific
1412
+ \`aud\` (none exists yet; tokens often carry \`aud: "account"\`, so with
1413
+ PyJWT set \`verify_aud: False\` and check the client yourself).
1414
+ - External mode: the client ID must appear in \`azp\` (or the provider's
1415
+ equivalent, e.g. \`cid\`, \`appid\`) **or** in \`aud\`. Never accept a
1416
+ token naming it in neither. If the provider allows open sign-up, "any
1417
+ signed-in user" means anyone: require a role.
1418
+ 403 when the token is valid but the role (or tenant) is wrong.
1419
+ 3. **Claims**: roles in \`realm_access.roles\` (built-in \`viewer\`/\`user\`/
1420
+ \`admin\` plus custom roles; ignore \`offline_access\`, \`uma_authorization\`,
1421
+ \`default-roles-*\`). Treat a missing or malformed \`realm_access\`/\`roles\`
1422
+ (not an object / not a list) as no roles \u2192 403, never a 500, and match
1423
+ whole role strings only. External mode: roles come from the customer's
1424
+ provider, in its own claim. For the user ID claim, follow the docs page
1425
+ /docs/apps/shell/sign-in#know-who-the-user-is.
1426
+ **Multi-tenant**: roles apply across every company (an \`admin\` in one
1427
+ company is admin everywhere), so filter every read and write by the
1428
+ \`organization\` claim too. It can hold more than one company: check the
1429
+ record's company is in it. Its exact form is unconfirmed; accept a list of
1430
+ names or an object keyed by name, anything else = no companies.
1431
+ 4. **Libraries**: Python PyJWT \`PyJWKClient(JWKS_URL, timeout=5)\` (it
1432
+ refetches the JWKS on every unknown \`kid\`, so keep the timeout short) +
1433
+ \`jwt.decode(token, key.key, algorithms=["RS256"], issuer=ISSUER,
1434
+ options={"require": ["exp", "iss"], "verify_aud": False})\`, then
1435
+ \`azp == CLIENT_ID\` (External: or \`cid\`/\`appid\`/\`aud\`), as a FastAPI
1436
+ dependency plus a \`require_role()\` dependency for 403. Node: \`jose\`
1437
+ \`createRemoteJWKSet\` + \`jwtVerify(token, JWKS, { issuer, algorithms:
1438
+ ["RS256"], requiredClaims: ["exp"] })\`, then the same client check; fail at
1439
+ startup if \`AUTH_ISSUER\` or \`AUTH_CLIENT_ID\` is unset (an undefined
1440
+ issuer disables jose's \`iss\` check). Full examples on the docs page.
1441
+ 5. **CORS**: the Shell site and the Service are different origins, so the
1442
+ browser preflights calls carrying \`Authorization\`. Allow the Shell site
1443
+ origin (and its custom domain), methods, and the \`Authorization\` and
1444
+ \`Content-Type\` headers (FastAPI \`CORSMiddleware\`; Express
1445
+ \`cors({ origin: ["https://<shell-site>"], allowedHeaders: ["Authorization", "Content-Type"] })\`,
1446
+ never bare \`cors()\`, which allows every origin), so preflight is answered
1447
+ before the token check. Never \`*\` with credentials; the bearer header
1448
+ needs no credentials mode.
1449
+
1450
+ ## Every app on the Shell site can use the user's token
1451
+
1452
+ Every app on a Shell site, including apps the customer didn't write, can get
1453
+ the signed-in user's token and call the customer's backends as that user, and
1454
+ a token from one app is accepted by every other app's backend in the
1455
+ workspace. Tell users to install only apps they trust and to check roles for
1456
+ anything sensitive. Per-app tokens, sign-in settings injected into the
1457
+ Service, and a verify helper are planned (no date). Do not promise per-app
1458
+ isolation.
1459
+
1460
+ ## Running off-the-shelf images as a Service
1461
+
1462
+ - Listen on 8080 (or \`PORT\`) on 0.0.0.0; many images default to 80, so
1463
+ reconfigure in the Dockerfile.
1464
+ - Managed databases are PostgreSQL only; MySQL/MariaDB/MongoDB apps
1465
+ (WordPress) need an external database.
1466
+ - **Finish the installer immediately**: apps with a first-visit installer
1467
+ (WordPress) can be claimed by anyone who opens the URL until it's completed.
1468
+ Tell the user to complete it as soon as the first deploy finishes.
1469
+ - Read secrets in an entrypoint script: pass the M2M credentials to curl on
1470
+ stdin (not argv), fail closed if the value is empty, \`unset\` the M2M
1471
+ credentials and token before \`exec\`ing the app.
1472
+ - Local disk is lost on restart, redeploy and scale-to-zero, and is per
1473
+ instance. Use the app's S3/object-storage plugin against external storage;
1474
+ Groundfloor Files is an API for code the customer writes, not an S3 endpoint.
1475
+ - More than one instance breaks in-memory/on-disk sessions; keep one instance
1476
+ or store sessions in the database. Set min instances \u2265 1 so the first visit
1477
+ doesn't wait.
1478
+ - Only the app's own login applies; Groundfloor sign-in can't front it today.
1479
+ `.trim();
1480
+
1261
1481
  // src/shell-guide.ts
1262
1482
  var SHELL_APP_GUIDE = `
1263
1483
  # Shell federated apps
@@ -1305,6 +1525,12 @@ outside Shell) so the breadcrumb does not show the raw id. Give that route
1305
1525
  routes (omit it or use \`false\` otherwise \u2014 never \`null\`). \`visibility:
1306
1526
  "public"\` makes a route reachable without login.
1307
1527
 
1528
+ Backends: secrets and server-side work go in a Coderunner Service the app
1529
+ calls with \`Authorization: Bearer \${await window.__GF_GET_ACCESS_TOKEN__()}\`.
1530
+ Shell sign-in does **not** protect that Service: its URL is public, so it must
1531
+ verify the token on every request and allow the Shell origin for CORS
1532
+ (\`docs_get\` id \`service-access\`).
1533
+
1308
1534
  ## suggest_cli
1309
1535
 
1310
1536
  - create_app \u2192 \`gf apps create --name "<App Name>" --slug <portal-slug> --kind shell_federated\` (register the App first; publish fails with App not found otherwise)
@@ -1513,11 +1739,19 @@ var DOC_CATALOG = [
1513
1739
  embedded: CODERUNNER_RUNTIME_CONTRACT,
1514
1740
  lastUpdated: "2026-10-08"
1515
1741
  },
1742
+ {
1743
+ id: "service-access",
1744
+ title: "Who can reach your Service",
1745
+ path: "/docs/deploy/securing-a-service",
1746
+ summary: "Every Service (Deployment URL or App domain) is public; Groundfloor signs nobody in and checks no token. Open sites and apps with their own logins need nothing (finish installers like WordPress immediately); a Shell app's backend must verify the user's JWT on every request: issuer and client_id copied once from GET /v1/public/workspaces/by-host (never from the request Host header), RS256 via the issuer's JWKS by kid, exact iss, exp, azp == client_id in Groundfloor mode (External: azp/cid/appid or aud), no app aud yet; roles in realm_access.roles (malformed = no roles = 403); with Multi-tenant also filter by organization. CORS for the Shell origin. Every app on the Shell site can use the user's token against any of the workspace's backends until per-app tokens ship. Off-the-shelf images: port 8080, PostgreSQL only, ephemeral disk, sessions, min instances.",
1747
+ embedded: SERVICE_ACCESS_GUIDE,
1748
+ lastUpdated: "2026-10-14"
1749
+ },
1516
1750
  {
1517
1751
  id: "authentication",
1518
1752
  title: "Workspace Authentication",
1519
1753
  path: "/docs/access/app-authentication",
1520
- summary: "App end users sign in only to the app via the workspace App Authentication realm; no Groundfloor console account or workspace membership. Modes none / Groundfloor / external, users and roles, SSO, multi-tenant, sign-in email (Groundfloor email by default, or your own server). Invite delivery (email_sent, email_skipped_reason) and what to do when an invite isn't sent: resend, generated temporary password, one-time invite link; 409 for people who can already sign in. App users' tokens work only on the end-user routes. Optional; not required to create Apps or Coderunners. Separate from portal Team.",
1754
+ summary: "App end users sign in only to the app via the workspace App Authentication realm; no Groundfloor console account or workspace membership. Modes none / Groundfloor / external, users and roles, SSO, multi-tenant, sign-in email (Groundfloor email by default, or your own server). Invite delivery (email_sent, email_skipped_reason incl. sending_limited / sending_paused / recipient_suppressed) and what to do when an invite isn't sent: resend, generated temporary password, one-time invite link; 409 for people who can already sign in. Email sending limits (200/hour and 1,000/day per workspace, 5/hour and 10/day per address), 429 Retry-After and 423 paused (X-Groundfloor-Reason header), the test email's ok:false when suppressed, suppressed addresses (spam reports cleared at the provider only by support), mail status. App users' tokens work only on the end-user routes. Optional; not required to create Apps or Coderunners. Separate from portal Team.",
1521
1755
  embedded: APP_AUTHENTICATION_GUIDE,
1522
1756
  lastUpdated: "2026-10-14"
1523
1757
  },
@@ -1545,7 +1779,7 @@ var DOC_CATALOG = [
1545
1779
  path: "/docs/apps/shell",
1546
1780
  summary: "Product Apps vs Coderunner. Always gf apps init --slug <portal-slug>, then npm run release and gf apps publish --path release.zip. Never zip dist/ or upload only remoteEntry.js.",
1547
1781
  embedded: SHELL_APP_GUIDE,
1548
- lastUpdated: "2026-10-08"
1782
+ lastUpdated: "2026-10-14"
1549
1783
  },
1550
1784
  {
1551
1785
  id: "design-standard",
@@ -1850,19 +2084,25 @@ server.tool(
1850
2084
  );
1851
2085
  server.tool(
1852
2086
  "workspace_context",
1853
- "Live context for the selected workspace: session, coderunners, apps, environments (with the default one), feature flags, dataplane (access layer, not a database), databases_howto, suggested gf commands. Use docs_get id coderunner before deploying; id databases for data work; id feature-flags for toggles/config; id css-and-styling for own CSS framework.",
2087
+ "Live context for the selected workspace: session, coderunners, apps, environments (with the default one), feature flags, dataplane (access layer, not a database), sign_in_email (App authentication email mode, paused or active, limits and use; null without workspace administer), databases_howto, suggested gf commands. Use docs_get id coderunner before deploying; id databases for data work; id feature-flags for toggles/config; id css-and-styling for own CSS framework.",
1854
2088
  { workspace_id: wsId },
1855
2089
  READ_ONLY,
1856
2090
  async ({ workspace_id }) => {
1857
2091
  try {
1858
2092
  const c = await cfg(workspace_id);
1859
2093
  const claims = decodeJwt(c.token) ?? {};
1860
- const [coderunners, apps, dataplane, environments, flags] = await Promise.all([
2094
+ const [coderunners, apps, dataplane, environments, flags, mail] = await Promise.all([
1861
2095
  listCoderunners(c.apiUrl, c.workspaceId),
1862
2096
  listApps(c.apiUrl, c.workspaceId).catch(() => ({ apps: [] })),
1863
2097
  getDataplaneStatus(c.apiUrl, c.workspaceId).catch(() => null),
1864
2098
  listWorkspaceEnvironments(c.apiUrl, c.workspaceId).catch(() => []),
1865
- listFlags(c.apiUrl, c.workspaceId).catch(() => null)
2099
+ listFlags(c.apiUrl, c.workspaceId).catch(() => null),
2100
+ // Workspace administer only; null for other callers or older cells.
2101
+ cpGetJson(
2102
+ c.apiUrl,
2103
+ c.token,
2104
+ `/v1/workspaces/${encodeURIComponent(c.workspaceId)}/mail/status`
2105
+ ).catch(() => null)
1866
2106
  ]);
1867
2107
  return text({
1868
2108
  session: {
@@ -1884,6 +2124,15 @@ server.tool(
1884
2124
  status: app.status
1885
2125
  })),
1886
2126
  dataplane,
2127
+ sign_in_email: mail ? {
2128
+ mode: mail.mode,
2129
+ state: mail.state,
2130
+ paused_reason: mail.paused_reason ?? null,
2131
+ limits: mail.limits,
2132
+ used: mail.used,
2133
+ suppressed_addresses: mail.suppressed_addresses,
2134
+ docs_get: "authentication"
2135
+ } : null,
1887
2136
  environments: environments.map((env) => ({
1888
2137
  id: env.id,
1889
2138
  name: env.name,
@@ -2356,7 +2605,7 @@ server.tool(
2356
2605
  "How-to for Coderunner, Shell, design-standard, css-and-styling, databases, access, m2m, authentication (app end users). Pass id from docs_list. Prefer id coderunner or coderunner-runtime before deploying. For site grants use id access. For Coderunner Vault/Files use id m2m.",
2357
2606
  {
2358
2607
  id: z.string().min(1).describe(
2359
- "Catalog id, e.g. coderunner | coderunner-runtime | shell | design-standard | css-and-styling | databases | agents | developers | authentication | access | m2m"
2608
+ "Catalog id, e.g. coderunner | coderunner-runtime | service-access | shell | design-standard | css-and-styling | databases | agents | developers | authentication | access | m2m"
2360
2609
  ),
2361
2610
  offset: z.number().int().min(0).optional().describe(
2362
2611
  "Live (non-embedded) pages only: character offset to resume from. Pass nextOffset from the previous result when truncated is true."
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@groundfloorcloud/mcp",
3
- "version": "0.2.3",
3
+ "version": "0.2.5",
4
4
  "description": "Groundfloor MCP — inspect workspace state and docs; take actions with the gf CLI.",
5
5
  "type": "module",
6
6
  "bin": {