@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.
- package/README.md +1 -1
- package/dist/index.js +270 -21
- 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
|
|
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(
|
|
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\`),
|
|
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\`).
|
|
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\`.
|
|
631
|
-
|
|
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}\`
|
|
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.
|
|
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.
|
|
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-
|
|
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."
|