create-ailk 0.29.1 → 0.31.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/README.md +71 -3
- package/component-catalog.json +524 -8
- package/component-catalog.md +8 -5
- package/dist/add-route.js +1 -0
- package/dist/cli.js +114 -1
- package/dist/cutover.d.ts +109 -0
- package/dist/cutover.js +494 -0
- package/dist/design-presets.js +20 -4
- package/dist/export-content.d.ts +55 -0
- package/dist/export-content.js +303 -0
- package/dist/inventory.d.ts +216 -0
- package/dist/inventory.js +833 -0
- package/dist/language-import.d.ts +114 -0
- package/dist/language-import.js +435 -0
- package/dist/lib/env-catalog.js +2 -2
- package/dist/lib/import-mdx.d.ts +38 -0
- package/dist/lib/import-mdx.js +411 -64
- package/dist/lib/render-comparison.d.ts +200 -0
- package/dist/lib/render-comparison.js +374 -0
- package/dist/lib/scaffold-render-sweep.d.ts +1 -1
- package/dist/lib/scaffold-render-sweep.js +1 -1
- package/dist/migrate-content.d.ts +6 -2
- package/dist/migrate-content.js +60 -14
- package/dist/module-architecture.js +6 -1
- package/dist/provision-env.d.ts +28 -0
- package/dist/provision-env.js +210 -0
- package/dist/seed-workspace.d.ts +3 -1
- package/dist/seed-workspace.js +45 -16
- package/dist/surfaces.js +3 -0
- package/dist/topology.d.ts +12 -0
- package/dist/topology.js +28 -10
- package/package.json +3 -3
- package/templates/.claude/cloudparity-repo-hooks.json +5 -0
- package/templates/.claude/hooks/content-source-guard.sh +75 -0
- package/templates/.claude/settings.json +9 -0
- package/templates/.env.example +7 -0
- package/templates/.github/workflows/ci.yml +2 -0
- package/templates/CLAUDE.md +5 -0
- package/templates/apps/api/CLAUDE.md +6 -2
- package/templates/apps/mcp/.env.example +20 -0
- package/templates/apps/mcp/CLAUDE.md +46 -8
- package/templates/apps/web/.env.example +4 -2
- package/templates/apps/web/CLAUDE.md +39 -5
- package/templates/apps/web/app/.well-known/ai-plugin.json/route.ts +9 -1
- package/templates/apps/web/app/.well-known/mcp.json/build-manifest.ts +58 -0
- package/templates/apps/web/app/.well-known/mcp.json/route.ts +16 -0
- package/templates/apps/web/app/.well-known/oauth-authorization-server/route.ts +53 -0
- package/templates/apps/web/app/[locale]/(auth)/__tests__/password-reset.test.tsx +8 -1
- package/templates/apps/web/app/[locale]/(auth)/consent/__tests__/consent-actions.test.ts +56 -0
- package/templates/apps/web/app/[locale]/(auth)/consent/__tests__/page.test.tsx +164 -0
- package/templates/apps/web/app/[locale]/(auth)/consent/consent-form.tsx +132 -0
- package/templates/apps/web/app/[locale]/(auth)/consent/consent.actions.ts +51 -0
- package/templates/apps/web/app/[locale]/(auth)/consent/page.tsx +144 -0
- package/templates/apps/web/app/[locale]/[slug]/[detail]/__tests__/page.test.tsx +153 -4
- package/templates/apps/web/app/[locale]/[slug]/[detail]/page.tsx +45 -2
- package/templates/apps/web/app/[locale]/about/__tests__/about-routes.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/contact/__tests__/site-contact-lines.test.ts +49 -0
- package/templates/apps/web/app/[locale]/contact/site-contact-lines.ts +6 -4
- package/templates/apps/web/app/[locale]/events/[slug]/page.tsx +13 -1
- package/templates/apps/web/app/[locale]/events/__tests__/program-event.test.tsx +120 -0
- package/templates/apps/web/app/[locale]/events/page.tsx +11 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +44 -5
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.thanks-by-tier.test.tsx +306 -0
- package/templates/apps/web/app/[locale]/layout.tsx +122 -27
- package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/page.test.tsx +21 -2
- package/templates/apps/web/app/[locale]/thanks/[slug]/page.tsx +1 -0
- package/templates/apps/web/app/api/auth/hub/continuation.ts +8 -3
- package/templates/apps/web/app/api/auth/mcp/__tests__/continuation.test.ts +92 -0
- package/templates/apps/web/app/api/auth/mcp/continuation.ts +89 -0
- package/templates/apps/web/app/api/auth/mcp/continue/[ref]/__tests__/route.test.ts +59 -0
- package/templates/apps/web/app/api/auth/mcp/continue/[ref]/route.ts +41 -0
- package/templates/apps/web/app/llms.txt/route.ts +26 -9
- package/templates/apps/web/app/openapi.json/__tests__/route.test.ts +40 -0
- package/templates/apps/web/app/openapi.json/route.ts +4 -0
- package/templates/apps/web/app/robots.txt/route.ts +18 -1
- package/templates/apps/web/app/sitemap.xml/route.ts +13 -0
- package/templates/apps/web/auth.ts +184 -7
- package/templates/apps/web/jest.config.cjs +46 -1
- package/templates/apps/web/lib/__tests__/auth-routes.test.ts +2 -1
- package/templates/apps/web/lib/__tests__/bot-check.test.ts +247 -4
- package/templates/apps/web/lib/__tests__/in-page-magnet-self-request.test.ts +92 -0
- package/templates/apps/web/lib/__tests__/page-chrome.test.ts +90 -0
- package/templates/apps/web/lib/__tests__/relay.test.ts +33 -8
- package/templates/apps/web/lib/__tests__/site-identity-feature-list.test.ts +196 -0
- package/templates/apps/web/lib/__tests__/structured-data.test.tsx +11 -1
- package/templates/apps/web/lib/auth-routes.ts +5 -3
- package/templates/apps/web/lib/bot-check.ts +113 -13
- package/templates/apps/web/lib/in-page-magnet.ts +4 -1
- package/templates/apps/web/lib/mcp-oauth.ts +702 -0
- package/templates/apps/web/lib/page-chrome.ts +65 -5
- package/templates/apps/web/lib/relay.ts +22 -3
- package/templates/apps/web/lib/site-identity.ts +75 -24
- package/templates/apps/web/messages/en.json +47 -14
- package/templates/apps/web/next.config.mjs +15 -1
- package/templates/apps/web/package.json +1 -1
- package/templates/apps/web/scripts/assert-content-bundled.ts +36 -8
- package/templates/apps/web/scripts/content-snapshot.ts +33 -0
- package/templates/database/seed.ts +6 -0
- package/templates/package.json +2 -1
- package/templates/tools/check-content-source.ts +108 -0
- package/versions.json +48 -48
package/dist/topology.js
CHANGED
|
@@ -63,8 +63,12 @@ export const SPOKE_HUB_CHECKOUT_SEEDS = [
|
|
|
63
63
|
export const SPOKE_REMOVAL_SEEDS = [
|
|
64
64
|
"apps/web/auth.ts",
|
|
65
65
|
"apps/web/lib/prisma-for-auth.ts",
|
|
66
|
+
// The MCP authorization server (#10357 D10): a spoke has no auth.ts to
|
|
67
|
+
// install it, and the root discovery route reads it.
|
|
68
|
+
"apps/web/lib/mcp-oauth.ts",
|
|
66
69
|
"apps/web/lib/license-key-store.ts",
|
|
67
70
|
"apps/web/app/api/auth",
|
|
71
|
+
"apps/web/app/.well-known/oauth-authorization-server",
|
|
68
72
|
"apps/web/app/[locale]/(auth)",
|
|
69
73
|
"apps/web/app/[locale]/(authed)",
|
|
70
74
|
...SPOKE_HUB_CHECKOUT_SEEDS,
|
|
@@ -899,19 +903,32 @@ export function writeHubOriginEnv(targetDir, origin) {
|
|
|
899
903
|
writeWebEnv(targetDir, HUB_ORIGIN_ENV_VAR, () => origin);
|
|
900
904
|
}
|
|
901
905
|
/**
|
|
902
|
-
* `<name>=<value()>` into
|
|
903
|
-
*
|
|
904
|
-
*
|
|
906
|
+
* `<name>=<value()>` into `apps/web/.env.local`, unless any env file Next
|
|
907
|
+
* loads for `apps/web` already assigns `name` (`value` is then never called);
|
|
908
|
+
* and `.env.local` gitignored either way. `true` when the line was written.
|
|
905
909
|
*/
|
|
906
|
-
function writeWebEnv(targetDir, name, value) {
|
|
907
|
-
|
|
910
|
+
export function writeWebEnv(targetDir, name, value) {
|
|
911
|
+
return writeEnvLine(join(targetDir, "apps", "web"), NEXT_ENV_FILES, ".env.local", targetDir, ENV_LOCAL_IGNORE_LINES, name, value);
|
|
912
|
+
}
|
|
913
|
+
/** The lines that gitignore `apps/api/.env`; `.env` is appended when none is there. */
|
|
914
|
+
const ENV_IGNORE_LINES = new Set([".env", "**/.env", ".env*"]);
|
|
915
|
+
/**
|
|
916
|
+
* `<name>=<value()>` into `apps/api/.env` — the one file the API's
|
|
917
|
+
* `dotenv.config()` reads — under `writeWebEnv`'s never-clobber rule, and
|
|
918
|
+
* `.env` gitignored either way (#10659 D6). `true` when the line was written.
|
|
919
|
+
*/
|
|
920
|
+
export function writeApiEnv(targetDir, name, value) {
|
|
921
|
+
return writeEnvLine(join(targetDir, "apps", "api"), [".env"], ".env", targetDir, ENV_IGNORE_LINES, name, value);
|
|
922
|
+
}
|
|
923
|
+
/** One app's never-clobber env append, and the root `.gitignore` guard. */
|
|
924
|
+
function writeEnvLine(appDir, files, target, targetDir, ignoreLines, name, value) {
|
|
908
925
|
const assigned = new RegExp(`^\\s*(?:export\\s+)?${name}\\s*=\\s*\\S`, "m");
|
|
909
|
-
const already =
|
|
910
|
-
const path = join(
|
|
926
|
+
const already = files.some((file) => {
|
|
927
|
+
const path = join(appDir, file);
|
|
911
928
|
return existsSync(path) && assigned.test(readFileSync(path, "utf8"));
|
|
912
929
|
});
|
|
913
930
|
if (!already) {
|
|
914
|
-
const file = join(
|
|
931
|
+
const file = join(appDir, target);
|
|
915
932
|
const existing = existsSync(file) ? readFileSync(file, "utf8") : "";
|
|
916
933
|
writeOwnerOnly(file, appendLine(existing, `${name}=${value()}`));
|
|
917
934
|
}
|
|
@@ -919,9 +936,10 @@ function writeWebEnv(targetDir, name, value) {
|
|
|
919
936
|
const ignore = existsSync(ignorePath) ? readFileSync(ignorePath, "utf8") : "";
|
|
920
937
|
const covered = ignore
|
|
921
938
|
.split("\n")
|
|
922
|
-
.some((line) =>
|
|
939
|
+
.some((line) => ignoreLines.has(line.trim()));
|
|
923
940
|
if (!covered)
|
|
924
|
-
writeFileSync(ignorePath, appendLine(ignore,
|
|
941
|
+
writeFileSync(ignorePath, appendLine(ignore, target));
|
|
942
|
+
return !already;
|
|
925
943
|
}
|
|
926
944
|
/** The env var naming the hub's web origin a hub-mode spoke signs in at (#7894 D11). */
|
|
927
945
|
export const HUB_SIGN_IN_URL_ENV_VAR = "AILK_HUB_SIGN_IN_URL";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-ailk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.31.0",
|
|
4
4
|
"description": "Scaffolder for AI Launch Kit — `pnpm create ailk@latest <dir>` produces a starter repo consuming @working-theory/* from npm (per ADR 0007).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -28,8 +28,8 @@
|
|
|
28
28
|
"tar": "^7.5.21"
|
|
29
29
|
},
|
|
30
30
|
"devDependencies": {
|
|
31
|
-
"@working-theory/schema": "0.
|
|
32
|
-
"@working-theory/templates": "0.
|
|
31
|
+
"@working-theory/schema": "0.54.0",
|
|
32
|
+
"@working-theory/templates": "0.54.0"
|
|
33
33
|
},
|
|
34
34
|
"repository": {
|
|
35
35
|
"type": "git",
|
|
@@ -5,6 +5,11 @@
|
|
|
5
5
|
"matcher": "Edit|Write",
|
|
6
6
|
"command": "bash .claude/hooks/template-guard.sh"
|
|
7
7
|
},
|
|
8
|
+
{
|
|
9
|
+
"event": "PreToolUse",
|
|
10
|
+
"matcher": "Edit|Write",
|
|
11
|
+
"command": "bash .claude/hooks/content-source-guard.sh"
|
|
12
|
+
},
|
|
8
13
|
{
|
|
9
14
|
"event": "PostToolUse",
|
|
10
15
|
"matcher": "Edit|Write",
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# PreToolUse (Edit|Write): refuse a new page file on a site whose pages come
|
|
3
|
+
# from the database. When CONTENT_ADAPTER=database — in the environment or in
|
|
4
|
+
# one of Next's eight env files under apps/web/ — a call whose file_path is a
|
|
5
|
+
# content/en/**/*.mdx that does not exist yet is refused (exit 2); the refusal
|
|
6
|
+
# on stderr names the MCP tools that change a page instead. An existing page
|
|
7
|
+
# file, any other path and a files site pass silently. The same rule and the
|
|
8
|
+
# same message as tools/check-content-source.ts, which the preflight runs.
|
|
9
|
+
# Pure bash and awk — nothing a scaffold has to install. Registered in
|
|
10
|
+
# .claude/settings.json and declared in .claude/cloudparity-repo-hooks.json;
|
|
11
|
+
# keep the two in step.
|
|
12
|
+
export LC_ALL=C
|
|
13
|
+
cd "$CLAUDE_PROJECT_DIR" || exit 0
|
|
14
|
+
|
|
15
|
+
IFS= read -r -d '' input
|
|
16
|
+
[[ $input =~ \"file_path\"[[:space:]]*:[[:space:]]*\"([^\"\\]*)\" ]] || exit 0
|
|
17
|
+
rel=${BASH_REMATCH[1]}
|
|
18
|
+
case $rel in
|
|
19
|
+
"$PWD"/*) rel=${rel#"$PWD"/} ;;
|
|
20
|
+
/*) exit 0 ;;
|
|
21
|
+
./*) rel=${rel#./} ;;
|
|
22
|
+
esac
|
|
23
|
+
case "/$rel/" in
|
|
24
|
+
*/./*|*/../*|*//*) exit 0 ;;
|
|
25
|
+
esac
|
|
26
|
+
# `*` crosses `/` in a case pattern, so this is content/en/**/*.mdx.
|
|
27
|
+
case $rel in
|
|
28
|
+
content/en/*.mdx) ;;
|
|
29
|
+
*) exit 0 ;;
|
|
30
|
+
esac
|
|
31
|
+
[ ! -e "$rel" ] || exit 0
|
|
32
|
+
|
|
33
|
+
# The source rule: the environment, else the first env file whose last
|
|
34
|
+
# CONTENT_ADAPTER assignment reads exactly `database`.
|
|
35
|
+
source_name=
|
|
36
|
+
if [ "${CONTENT_ADAPTER-}" = database ]; then
|
|
37
|
+
source_name="the environment"
|
|
38
|
+
else
|
|
39
|
+
for name in .env .env.local .env.development .env.development.local \
|
|
40
|
+
.env.production .env.production.local .env.test .env.test.local; do
|
|
41
|
+
file=apps/web/$name
|
|
42
|
+
[ -f "$file" ] || continue
|
|
43
|
+
value=$(awk -v sq="'" '
|
|
44
|
+
/^[ \t]*(export[ \t]+)?CONTENT_ADAPTER[ \t]*=/ {
|
|
45
|
+
v = substr($0, index($0, "=") + 1)
|
|
46
|
+
n = length(v); quote = ""; cut = n
|
|
47
|
+
for (i = 1; i <= n; i++) {
|
|
48
|
+
c = substr(v, i, 1)
|
|
49
|
+
if (quote != "") { if (c == quote) quote = ""; continue }
|
|
50
|
+
if (c == "\"" || c == sq) { quote = c; continue }
|
|
51
|
+
if (c == "#" && i > 1 && substr(v, i - 1, 1) ~ /[ \t]/) { cut = i - 1; break }
|
|
52
|
+
}
|
|
53
|
+
v = substr(v, 1, cut)
|
|
54
|
+
sub(/^[ \t\r]+/, "", v); sub(/[ \t\r]+$/, "", v)
|
|
55
|
+
f = substr(v, 1, 1)
|
|
56
|
+
if (length(v) >= 2 && (f == "\"" || f == sq) && substr(v, length(v), 1) == f) {
|
|
57
|
+
v = substr(v, 2, length(v) - 2)
|
|
58
|
+
}
|
|
59
|
+
last = v
|
|
60
|
+
}
|
|
61
|
+
END { print last }
|
|
62
|
+
' "$file")
|
|
63
|
+
if [ "$value" = database ]; then
|
|
64
|
+
source_name=$file
|
|
65
|
+
break
|
|
66
|
+
fi
|
|
67
|
+
done
|
|
68
|
+
fi
|
|
69
|
+
[ -n "$source_name" ] || exit 0
|
|
70
|
+
|
|
71
|
+
printf '%s\n' >&2 \
|
|
72
|
+
"content-source: $rel is a page file, but this site's pages come from the database (CONTENT_ADAPTER=database in $source_name), so the site never reads it." \
|
|
73
|
+
"Change a page through the site's MCP tools instead: save_draft writes the draft and publish_draft publishes it; read_content returns the current page." \
|
|
74
|
+
"Pages and layout: words live in the database, layout stays in code (CLAUDE.md § Content)."
|
|
75
|
+
exit 2
|
package/templates/.env.example
CHANGED
|
@@ -606,6 +606,13 @@ CONTENT_ADAPTER=mdx
|
|
|
606
606
|
# docs/runbooks/site-configuration.md § Seeding a spoke's flows.
|
|
607
607
|
# AILK_SPOKE_FLOW_SEED=
|
|
608
608
|
#
|
|
609
|
+
# HUB (apps/api, optional) — the always-loaded set's token budget (#10504):
|
|
610
|
+
# every Brief's body plus the Memory index an agent loads on each turn,
|
|
611
|
+
# estimated at four characters a token. Past it the knowledge-base read sets
|
|
612
|
+
# `warning: true` and still returns every doc whole. A whole number from 1 to
|
|
613
|
+
# 1000000; unset means 8000. Any other value stops the API at boot.
|
|
614
|
+
# AILK_CONTEXT_BUDGET_TOKENS=
|
|
615
|
+
#
|
|
609
616
|
# SPOKE (apps/web and apps/mcp, server-only — never NEXT_PUBLIC_). The key the
|
|
610
617
|
# hub's `spokes register` printed. Sent as `x-ailk-spoke-key` on every
|
|
611
618
|
# server-side call to AILK_API_URL, and only when that is https (or
|
|
@@ -67,6 +67,8 @@ jobs:
|
|
|
67
67
|
# A hand-edit inside a template-owned section of CLAUDE.md — the same
|
|
68
68
|
# check `pnpm preflight` runs locally.
|
|
69
69
|
- run: pnpm claude-md:check
|
|
70
|
+
# A new page file on a site whose pages come from the database (#10660).
|
|
71
|
+
- run: pnpm content-source:check
|
|
70
72
|
|
|
71
73
|
- run: pnpm test
|
|
72
74
|
|
package/templates/CLAUDE.md
CHANGED
|
@@ -178,6 +178,11 @@ sections:
|
|
|
178
178
|
Copy an existing file in the same folder, then change it. Text in `{braces}`
|
|
179
179
|
is placeholder copy. Don't invent a `type` that `@working-theory/schema`
|
|
180
180
|
doesn't export.
|
|
181
|
+
|
|
182
|
+
Where a page lives depends on `CONTENT_ADAPTER` in `apps/web/.env.local`.
|
|
183
|
+
Unset or `mdx` (the default): the file above. `database`: the database —
|
|
184
|
+
change a page with the MCP tools `save_draft` then `publish_draft`; a new
|
|
185
|
+
file under `content/en/` is refused by a hook and by `pnpm preflight`.
|
|
181
186
|
<!-- /ailk:template id="content" -->
|
|
182
187
|
|
|
183
188
|
<!-- ailk:template id="hard-floors" -->
|
|
@@ -66,7 +66,7 @@ Routes are versioned under `/v1/*`:
|
|
|
66
66
|
- `/v1/flows/:slug/save` (POST), `/v1/flows/:slug/checkout` (POST) — the recommender's email moment (#4841): `save` creates the FlowSession + Lead (+ the T+0 DeliverableGrant) for the permalink's answers; `checkout` does the same, records the `FlowCheckout` buyer row at a SERVER-recomputed `price()` (the body carries no amount — `flowCheckoutInputSchema` is strict), and opens a Stripe TEST-mode Checkout Session whose metadata carries the permalink + offer key (`src/routes/flows/recommender.ts`, `src/services/recommender-capture.ts`, `packages/stripe-client`'s `createQuotedCheckoutSession`). Anonymous, rate-limited; a non-`sk_test_` key is refused before any side effect. **An Offerings offer is never quoted here (#9071 D2):** a `checkout` whose register offer or flow config offer carries `lookupKey` is `409 checkout_not_configured` before any write (the flow config's is checked inside the capture, before the email moment's session and lead — #9970 D9) — logged `{ slug, outcome: "offerings_offer_refused" }`; such an offer is sold on `/pricing` at Stripe's own Price. No MCP twin — recorded exception: an agent walking a flow has no email to give and no card to pay with; the anonymous MCP surface is identity-blind.
|
|
67
67
|
- `/v1/workspaces/:workspaceId/magnets` (GET, POST), `/v1/workspaces/:workspaceId/magnets/:id` (GET, PATCH), `/v1/workspaces/:workspaceId/magnets/:id/signups` (GET), `/v1/workspaces/:workspaceId/magnets/:id/subscribers` (GET), `/v1/workspaces/:workspaceId/magnets/:id/messages` (GET) — the tenant-scoped magnet Admin authoring surface over the flow engine's `FlowDefinition` rows (#6157 S5c, `src/routes/magnets/index.ts`): list (cursor-paginated, fixed 50-row page), create, get, update, and the three per-magnet captured-lead reads (#6246 S6h). Reads and writes only through `tenantDb(tenant).flows` — never `prisma.flowDefinition`, `currentSiteId()`, or `upsertFlowDefinition` (the PUBLIC/seed path below, which the admin path never calls). No DELETE — the reversible alternative is `status: "draft"` (#6156), which stops a NEW walk from starting while an in-flight walk finishes on its pinned snapshot. `slug` lives only inside `config.slug`. **A PATCH whose `config.slug` differs renames the magnet (#8641 D8):** `tenantDb(tenant).flows.rename` moves the row, this workspace's `Lead.source = 'flow:<slug>'` rows and its `FlowSession.flowSlug` rows to the new slug in one transaction, so its submissions follow it; `FlowCheckout` rows and lead payload copies are never rewritten. Refused before any write: `409 slug_fixed` (the stored slug is `onboarding`, `request-quote`, `contact` (#8661 D5), `job-application` (#8746 D3), `newsletter` (#8847 D4), `recommender` (#9071 D12) or `trial` (#9287 D11), which the server reads by name, or the row belongs to a listing) and `409 slug_reserved` (the new slug is one of those seven); inside the transaction, `409 conflict` first (another magnet holds it — #8734 D1; also the loser of two renames racing for one free slug, which fails on the `(site_id, slug)` key because the definition row is the rename's first write, D4), then `409 slug_bound` (a checkout or a waitlist register of this workspace names either slug, or a session of this workspace already sits at the new one). A lead of this workspace at `flow:<new slug>` that no magnet owns does not refuse: the rename re-stamps it `unclaimed:flow:<new slug>` (`unclaimedLeadSource`, `@working-theory/validation`) so the magnet never adopts it (#8734 D3). A rename records a `magnet.rename` audit row whose summary is `{ from, to, leads, sessions, unclaimed }`. The two operator-writable config URLs (`abandonEmail.resumeUrl`, `followUps[].cta.href`) pass an exact-origin gate against `FRONTEND_URL` before any write, the same discipline the recommender's checkout redirect already applies (#4912). Role floors from `@working-theory/validation`'s permission table (#7636 D2) (D5): list and get serve the full row to `magnets-manage` (owner, admin) and, to a caller holding only `magnets-read` (a member), a LIVE-only summary of exactly `{ id, slug, kind, title, landingPage }` — a member's list is read to completion (at most 20 pages of 50, a `warn` at the cap) and answers `nextCursor: null`, a member's `?cursor=` is a 400, and a draft's get is the same 404 as an absent id — because a magnet's `config` carries `scoring` thresholds, `offers`/`rateTable` and `deliverables` asset keys and no draft is a member's; POST and PATCH need `magnets-manage`; the per-magnet captured-people lists need `inbox-read` (every role), and for a caller without `magnets-manage` a magnet that is not live is the same 404. Both writes declare a route-level `bodyLimit` (256 KiB) and each records one `recordAgentAction` audit row. **`customersEnabled` (#8171 D6)** — the member-area customer switch — is served on every full row and written by the PATCH (a PATCH of it alone skips the plan check); the member summary row never carries it. **The completion setting (#8757 D1–D4):** every magnet carries `completion` (`lead` | `invite` | `checkout`, column default `lead`) and `checkoutOffering` (set exactly when `checkout`), two `FlowDefinition` columns closed by three CHECKs and read and written only through `lib/flow-completion-store.ts` (`tenant-db.ts` stays closed under the #8746 contract; `architecture.yaml` lists the store under `paths_scoped`). The migration backfilled `onboarding`, `request-quote` and `job-application` to `invite` by slug (`STANDARD_MAGNET_COMPLETIONS`); a magnet created later is `lead` until #8759. Every full row serves the pair; the member summary never does. The PATCH writes it ALONE — `{ completion, checkoutOffering }` beside any other key is `400 completion_alone`, an offering without a completion or a pair `flowCompletionSettingSchema` refuses `400 validation_failed` — with no plan check, and records `magnet.update` with summary `{ completion }`. `checkout` also needs a person (`403 completion_needs_person` for an agent key), the `commerce` claim (`409 commerce_required`, `503 license-undetermined`) and a key `services/plan-offerings.ts`'s `listPlanOfferings` lists right now (`409 offering_unknown`; a failed read `503 offerings_unavailable`). **The standard onboarding magnet (#8515 D2–D4):** every workspace holds one `diagnostic` magnet at slug `onboarding` (`ONBOARDING_FLOW_SLUG`, `onboardingFlowConfig` in `@working-theory/validation`), `draft` and `customersEnabled: true`, written only by `lib/tenant-db.ts`'s insert-only seed (`createMany` with `skipDuplicates`, so a re-seed creates nothing and never overwrites the owner's edits or an owner's own magnet at that slug): `POST /v1/workspaces` seeds the workspace it just created, after its transaction commits (a failed seed is one `error` line and still a `201`), and the seed-presets CLI (`node dist/bin/seed-presets.js`, run once after deploy) seeds every existing workspace and prints counts only. Its two bare-id exports have one production caller each, pinned by `tests/architecture/onboarding-seed-callers.test.ts`. **The standard Contact form magnet (#8661 D1–D3):** every workspace also holds one `contact` magnet at slug `contact` (`CONTACT_FORM_FLOW_SLUG`, `contactFormFlowConfig`), titled "Contact form", `live`, `customersEnabled: false`, landing page `/contact` — Name, Email, a five-option Subject, Message and a required terms checkbox. It is written by the same insert-only seed, from the same two callers (a failed seed is one `contact-form-seed` `error` line and still a `201`), and by `database/seed.ts` for the default workspace; a re-seed never duplicates it or overwrites an edit. The web /contact page reads it on every request through `GET /v1/flows/contact`, and a submission that used it is stamped `flow:contact`, an Inbox submission under "Contact form". **The standard Job application magnet (#8746 D3, D4):** every workspace also holds one `diagnostic` magnet at slug `job-application` (`JOB_APPLICATION_FLOW_SLUG`, `jobApplicationFlowConfig`), titled "Job application", `live`, `customersEnabled: false`, landing page `/careers` — First name, Last name and Email (required), Phone and LinkedIn URL — with no `completion` field. It is written by the same insert-only seed, from the same two callers (a failed seed is one `job-application-seed` `error` line and still a `201`), and by `database/seed.ts` for the default workspace; `RESERVED_FLOW_SLUGS` carries its slug, so no flow-engine write, spoke seed or new listing takes it. Each web job page `/careers/<slug>` carries an Apply button to `/careers/<slug>/apply` (#8849), whose Free apply page reads it through `GET /v1/flows/job-application` and draws its field step as a one-step form, and a submission is stamped `flow:job-application`, an Inbox submission under "Job application" whose `name` is first + last and whose `job` is the posting's slug. **The standard trial magnet (#9287 D11, #9301):** every workspace also holds one `diagnostic` magnet at slug `trial` (`TRIAL_FLOW_SLUG`, `trialFlowConfig`), titled "Free trial", `live`, `customersEnabled: false`, completion `lead`, landing page `/trial` — First name, Last name, Work email and a required terms box; a site may add a `company`-tagged field, which fills the Person's company. It is written by the same insert-only seed, from the same two callers (a failed seed is one `trial-seed` `error` line and still a `201`), by `database/seed.ts` for the default workspace and by the `20261025120000_trial_magnet` migration for existing workspaces; `RESERVED_FLOW_SLUGS` carries its slug. A completed walk is stamped `flow:trial`, an Inbox submission under "Free trial" whose Person carries the given name, family name and email. Module: `multi-tenant || signal-collectors`, the same gate the review-surface dashboard (`/v1/waitlist-experiments/*`, `/v1/project-listings/*`) already registers under. No MCP twin on any of the seven — identity-bound tenant routes (#3591 exception, same shape as the sibling `/v1/workspaces/*` routes); the public `/v1/flows/*` routes and their two MCP tools are unchanged, pinned by `src/routes/flows/__tests__/parity.test.ts`. **The three new list routes (#6246 S6h)** join every magnet through `Lead.source = 'flow:' + <the resolved magnet's slug>` — the literal string `flow-engine.ts`'s `completeSubmission` stamps on every flow-engine `Lead` row regardless of kind, including `newsletter`/`waitlist` (`Subscriber` is written only by the separate, pre-flow-engine `subscribe_newsletter` path, unrelated to a magnet) — via ONE new `tenantDb(tenant).leads.listBySource(source, { limit })` accessor, an exact-source-equality sibling of #6204's allowlist-scoped `listUnhandledMessages`, served by a new `(workspaceId, source)` composite index on `Lead`. `/signups` expects `kind: "waitlist"`, `/subscribers` expects `"newsletter"`, `/messages` expects `"contact"`; a magnet id absent from the caller's workspace OR resolved to the WRONG kind for the called route is `404 not_found`, indistinguishably (mirrors the existing GET's absent-or-foreign fold). `/signups` and `/subscribers` serve four keys (`id`, `name`, `email`, `createdAt`); `/messages` serves those four plus `handledAt`. Bounded `?limit=` (default 50, max 200), newest-first — mirrors `workspaces/messages.ts`'s own query schema. Applications (Listicle) is deliberately OUT of this surface — `ProjectListing` carries no reverse link to the listicle-kind `FlowDefinition` that captured it, so "scoped to that magnet" cannot be honored without a schema change (split to #6314).
|
|
68
68
|
- `/v1/deliverables/open/:slug` (GET) — an UNGATED file deliverable's bytes, keyed by the flow slug (#8848 D5, `deliverableOpenRoute` in `src/routes/deliverables/index.ts`). Anonymous; the workspace is the caller's (`resolveCallerWorkspace`, keyless through the one-workspace predicate as the flows bullet says; a refused spoke key or an ambiguous deployment `503 tenant_unresolved`), never a request value; no `DATABASE_URL` is `503 deliverables_not_configured`. The flow must be live and carry `deliverable` with `kind: "file"` and `gated: false`; the bytes are its `deliverables` default entry's `Asset`, through `lib/deliverable-asset-store.ts` (a client's file refused), never the legacy store, never a `playbook:` key; served with the grant route's `asset_` headers (`attachment`, pinned extension, `nosniff`, the sandbox CSP, `private, no-store`). Every miss is the file's one uniform 404. Registered in its own encapsulated `deliverableopen` rate-limit scope (60 a minute) with the visitor forward-key digests, and on `FORWARDED_VISITOR_ROUTES`, so a site's proxied reads (`/reports/<slug>/file`) are counted per visitor; the grant route's own budget is unchanged. Module: `signal-collectors`. No MCP twin — a byte download on the grant route's footing, recorded in `tests/parity/README.md`.
|
|
69
|
-
- `/v1/content/{types,search,offerings,pricing,shown-offerings,:type,:type/:slug}` — public content reads. `GET /v1/content/search`, `GET /v1/content/:type` and `GET /v1/content/:type/:slug` are `search_content`'s, `list_pages`' and `read_content`'s twins (#7167, #7168, #7169): each parses its request with the `@working-theory/validation` exports in `content-reads.ts` (`contentSearchQuerySchema`; `contentListParamsSchema` + `contentListQuerySchema`; `contentPageParamsSchema` + `contentPageQuerySchema`) and answers through the shared builder in `@working-theory/content-adapters` the tool also calls (`searchContentItemList`, `listPagesItemList`, `readContentDocument`), so the pair takes the same arguments and returns the same result — the tool adopted the API; the `/v1` wire shapes are unchanged. `GET /v1/content/offerings` (`?type=&locale=`) is `get_offerings`' twin (#7177), answering `{ ok: true, data: { offerings } }` through `listOfferings` in `@working-theory/content-adapters`, the seam the tool also calls. `GET /v1/content/pricing` (`?slug=&locale=`) is `get_pricing`'s twin (#7178), answering `{ ok: true, data: { tiers } | null }` through `getPricingTiers`. `GET /v1/content/shown-offerings` is the `/pricing` page's Offerings read (#8924), answering `{ ok: true, data: { status: "shown", shownSet } | { status: "unconnected" } | { status: "empty" } }` behind the `commerce` claim, refreshing the snapshot on the bounded non-forced path, with no MCP twin. On a connected workspace with a current shown set, `GET /v1/content/pricing` (the `pricing` page) and `GET /v1/content/offerings` (unless `type=service`) answer that set through `readShownOfferings` (exported from `shown-offerings.ts`, the one read all three share, registered with one `shownSetRouteOptions` in `server.ts`) and `@working-theory/content-adapters`' shown-set mapping (`pricingTiersFromShownSet`, `listOfferingsWithShownSet`), with major-unit prices, `interval` and the full `lookupKey` (offerings: the Service pages, then the shown Products in place of the Product pages, each with `prices`); every other case — no connection, no claim, an expired or empty set, a failed read or mapping (one `warn { outcome: "shown_set_failed", errName }`) — answers page content as before; their MCP twins read the same answer through `apiClient.offerings.shown` (#8933).
|
|
69
|
+
- `/v1/content/{types,search,offerings,pricing,shown-offerings,discovery,:type,:type/:slug}` — public content reads. `GET /v1/content/search`, `GET /v1/content/:type` and `GET /v1/content/:type/:slug` are `search_content`'s, `list_pages`' and `read_content`'s twins (#7167, #7168, #7169): each parses its request with the `@working-theory/validation` exports in `content-reads.ts` (`contentSearchQuerySchema`; `contentListParamsSchema` + `contentListQuerySchema`; `contentPageParamsSchema` + `contentPageQuerySchema`) and answers through the shared builder in `@working-theory/content-adapters` the tool also calls (`searchContentItemList`, `listPagesItemList`, `readContentDocument`), so the pair takes the same arguments and returns the same result — the tool adopted the API; the `/v1` wire shapes are unchanged. `GET /v1/content/offerings` (`?type=&locale=`) is `get_offerings`' twin (#7177), answering `{ ok: true, data: { offerings } }` through `listOfferings` in `@working-theory/content-adapters`, the seam the tool also calls. `GET /v1/content/pricing` (`?slug=&locale=`) is `get_pricing`'s twin (#7178), answering `{ ok: true, data: { tiers } | null }` through `getPricingTiers`. `GET /v1/content/shown-offerings` is the `/pricing` page's Offerings read (#8924), answering `{ ok: true, data: { status: "shown", shownSet } | { status: "unconnected" } | { status: "empty" } }` behind the `commerce` claim, refreshing the snapshot on the bounded non-forced path, with no MCP twin. On a connected workspace with a current shown set, `GET /v1/content/pricing` (the `pricing` page) and `GET /v1/content/offerings` (unless `type=service`) answer that set through `readShownOfferings` (exported from `shown-offerings.ts`, the one read all three share, registered with one `shownSetRouteOptions` in `server.ts`) and `@working-theory/content-adapters`' shown-set mapping (`pricingTiersFromShownSet`, `listOfferingsWithShownSet`), with major-unit prices, `interval` and the full `lookupKey` (offerings: the Service pages, then the shown Products in place of the Product pages, each with `prices`); every other case — no connection, no claim, an expired or empty set, a failed read or mapping (one `warn { outcome: "shown_set_failed", errName }`) — answers page content as before; their MCP twins read the same answer through `apiClient.offerings.shown` (#8933). `GET /v1/content/discovery` is the owner's discovery switch as the deployment itself reads it (#10510, `packages/api/src/routes/content/discovery.ts`): no argument (a query string is ignored); with no `DATABASE_URL` it answers `{ ok: true, data: { discoverable: true } }` before any resolver or database import; otherwise the workspace is `resolveCallerWorkspace`'s (a spoke's key, else the one workspace) — `unresolved` is `404 not_found`, `resolved` answers `readSiteDiscovery` (`packages/api/src/services/site-discovery.ts`, the one reader and writer of the `site.discovery` `SiteSetting` row; no row or an unparsable one reads as on), a throw is `500 internal_error` logged by class name only; every answer appends `x-ailk-spoke-key` to `Vary` (`lib/vary-spoke-key.ts`, the one copy the offerings, pricing and shown-offerings routes share). apps/web and apps/mcp read it through `siteDiscoverable()` in `@working-theory/api-client`; no MCP twin, by decision.
|
|
70
70
|
- `/v1/auth/tokens`, `/v1/user/{profile,data/:key}` — local auth + user data
|
|
71
71
|
- `/v1/hub-sign-in/codes` (POST), `/v1/hub-sign-in/token` (POST), `/v1/hub-sign-in/revoke` (POST), `/v1/hub-sign-in/me` (GET) — the hub signs in its spokes (#7894, `packages-paid/api-hub/src/routes/auth/hub-sign-in.ts` over `packages-paid/api-hub/src/services/hub-sign-in.ts` — `@working-theory/api-hub` since #9571, which owns the spoke registry and the sign-in trio and hands them to `buildServer({ hub })` through `packages/api/src/lib/hub.ts`; spec `docs/specs/7894-the-hub-signs-in-its-spec.md`): an authorization code with PKCE between a spoke (a confidential client whose `OAuthClient.clientId` IS its `Spoke.id`) and this hub. `codes` is in `authScope` and answers a COOKIE session only (a personal `ApiToken` is 403, an agent key 403 at the preHandler, a hub credential 404): it mints a single-use 60 s code only for an email-verified person and a callback in the client's registered `redirectUris` (exact match), 30 a user per 10 minutes. `token` and `revoke` are in their OWN scope outside `authScope` (per-IP `rateLimitMiddleware`, scope `hubsignin`, 60/min; plus a per-client bucket of 120 exchanges a minute keyed on the VERIFIED client): the caller authenticates with `Authorization: Basic clientId:secret` AND `x-ailk-spoke-key`, which must resolve to the unrevoked spoke whose id is that client — either alone is `401 invalid_client`. `token` consumes the code first and exactly once, then checks the client, the callback and the PKCE verifier (`400 invalid_grant` for any failure, the code gone either way) and issues `ailk_hst_…`, a 24 h credential stored only as a SHA-256 digest in `OAuthAuthorization` (one row per person and spoke, so a new sign-in rotates it); it writes no `Session` row and sets no cookie. `revoke` ends a credential or burns an unexpired code of that client (`{ revoked }`, never an error). `me` answers the credential's person, read live, and is reachable only by that credential, and `billingCustomerId` — the Stripe customer of the caller's Person in the spoke's workspace, read only while that Person's `userId` is the caller and the id is the caller's own Stripe binding, else `null`; since #8047 no anonymous checkout writes it, and the one writer is the hub site's signed-in checkout, so on a spoke's workspace it is `null` until a later writer exists. Its first read links the caller's Person under the verified-email rule (`stampPersonUser` through `resolveMemberPersonId`), so this GET can write. **The credential is admitted by one branch of `middleware/auth.ts`** — only beside the same spoke's key, only while its grant is unrevoked and unexpired, its client enabled and its person's email verified, and only on `HUB_SESSION_ROUTES`, an exact list of 26 method + registered-URL strings, never a prefix (#8220): `GET /v1/hub-sign-in/me`, #7877's three member reads, #8169's eight member reads, #8170's six member writes, #8435's two contact routes, #8438's two consent routes, #8765's account-agreements pair and #8515's onboarding read and write; every other route answers `404` with no handler run (the status the account area's `read()` loads as "nothing for this caller"), and the list is extended only by a reviewed edit. **The per-person budget (#8220 D2, SEC-8169-4):** each person at each spoke gets 60 requests and 10 writes a minute and 30 writes an hour (a fixed hour window from the first admitted write, so up to twice that across a window edge), keyed on the VERIFIED spoke and person and charged only after admission (a refused credential or an off-list route never gets a bucket); over budget is `429 rate_limited` with `Retry-After`. The write limits refuse only writes, and a refused request is not counted. Every line the request logger writes for an admitted request after admission carries `spokeId` (never the key or the credential); a helper logging through the module-level `logger` carries no request binding. The server-wide per-IP limiter still counts all of one spoke's members together — a named residual whose follow-up (the visitor forward key on member calls) must land before Phase 3 (#8163) ships to a live spoke. A bearer write skips the origin check, so the cross-site-request protection for the member writes is the spoke's: its server actions that send a sealed credential carry it (Phase 3, #8163; SEC-8220-11). A completed password reset (`apps/web/auth.ts` `onPasswordReset`) revokes every credential of that person. The operator registers a spoke's client with `spokes sign-in-client --id <spoke_id> --redirect-uri <url>` (`packages-paid/api-hub/src/bin/spokes.ts`; `--disable` switches it off) — `packages-paid/api-hub/src/lib/hub-sign-in-store.ts` is the only file that names `prisma.oAuthClient` and `prisma.oAuthAuthorization`. **The `hub` claim (#9571 D4; #9568 D5):** read only inside `@working-theory/api-hub` (`src/claim.ts`), through `checkLicense()`, registry first — with no `DATABASE_URL` or no registered spoke the gate answers `not-serving` and never reads the licence; with a spoke and the claim, `claimed`; with a spoke and no claim, `unclaimed`, and `codes`, `token`, `me` (a verified credential) and `clients/:clientId` answer `403 feature_required` naming `hub`; an unreadable licence is `undetermined`, `503 license_undetermined` with `Retry-After: 300`. `revoke` is never gated; a definite answer is held 300 s, `not-serving` 30 s, `undetermined` never; `spokes register` refuses without the claim (`hub-claim-missing`). Lead capture, the resolver and magnet completion never read it, and a boot without the package answers as an empty registry (the four routes `404`). No MCP twin for any of the four — a credential exchange between a site and its hub is not an agent-facing call (#3591 exception, the `/v1/auth/tokens` footing). The browser step lives on the hub's web app: `apps/web/app/api/auth/hub/authorize/route.ts`.
|
|
72
72
|
- `/v1/checkout/sessions` — start a Stripe checkout. **Two paths (#8931, `src/routes/checkout/sessions.ts` over `src/services/offerings/checkout-authorization.ts`; spec `docs/specs/8931-offerings-checkout-authorization-server-side-spec.md`; contract A1–A11, A13).** The path comes from `resolvePublicWorkspace()` and the workspace's own Organization alone — no body field names either: a resolved workspace whose Organization has a Stripe connection takes the **Offerings path**; a presented `x-ailk-spoke-key` the resolver refuses is `503 { error: "tenant_unresolved" }` with no Stripe call; a corrupt connection, or a database read that fails while choosing, is `502 checkout-unavailable`; everything else (keyless, `ambiguous`, off the database adapter, no Organization, no connection) takes the **env path**, today's code. **A spoke authenticates with its key on a hub that sets `OSS_API_INTERNAL_SECRET` (#9910; spec `docs/specs/9910-a-spoke-can-start-an-spec.md`):** a call with no `x-internal-auth` header and an `x-ailk-spoke-key` `resolveCallerSite` resolves to a registered, unrevoked spoke is a spoke call — it sells only that spoke's own workspace (a path naming any other is `401`), and its `successUrl` and `cancelUrl` must be on the origin of the spoke's enabled hub sign-in client's first redirect URI (`registeredSpokeOrigins`, read through `installedHub()`), else `400 validation-error`; a spoke with no such origin is `500 config-error`, both before any Stripe call. A refused key, no credential at all, or a presented `x-internal-auth` that does not match is `401 internal-auth-required` — the keyless and wrong-secret `401` before any read — and a matching `x-internal-auth` keeps its redirects. On a hub without the secret every caller is unchanged. **The env path is the hub's own-products path only, never a spoke's (#9142):** a spoke sells only through its `apps/web` Offerings buy route, which calls this route only for a lookup key its connected workspace's shown set holds, so a spoke's buy always takes the Offerings path; the spoke's own env-key buy route and Stripe webhook are retired. The Offerings path charges `chargeWriteBudget` scope `offeringscheckout` before the body is read — 10 a minute per `request.ip` and 60 per workspace (`OFFERINGS_CHECKOUT_PER_WORKSPACE_PER_MINUTE`), charged one at a time with the IP first, so a request its IP bucket refuses never spends the workspace's, whatever the process's mode — answering `429 { error: "rate_limited", retryAfterSeconds }` with `Retry-After`. It parses `productCode` (the full lookup key) by the one grammar first, builds the connection's own client (`stripeClientForOrganization` with `{ storedOnly: true }` — the stored connection only, never the environment key, so a connection deleted between the path read and this one is `502`, #9346), reads org-enabled ∩ workspace-shown from the rows (`getSellableProductIds`, never the snapshot), looks the key up live on that client, and sells only when the Price's own Product is in the set — and, for a workspace saved per Price (#9070 D5), the Price itself is one of its stored shown Prices (`getSellableProductIds`' `priceIds`; `null` for a row saved per Product) — and `normalizeCatalog` keeps that Price under that key and period. Every miss — ungrammatical, not enabled, not shown, moved, archived, nonexistent — is the one `404 { error: "unknown-product-code" }`, never echoing the key; Stripe, the rows or the connection failing is `502`, never the snapshot or the env key. The session is anonymous (no `customer`, no signed-in buyer, so no `ailk_user_id`), sells the authorized Price id, allows promotion codes, `customer_creation: "always"` in payment mode, and is stamped by the server `metadata` and `subscription_data.metadata` `{ site: <workspaceId>, ailk_offering: <lookup key> }`; it is recorded through `recordCheckoutSession` before the route answers, and a failed record expires it on the same client and answers `502`. **The env path refuses an Offerings product (A9):** `createCheckoutSession` gets `{ refuseProduct: isOfferingsProduct }`, so a Price whose Product carries `metadata.ailk_group` (or whose Product did not expand) is the same `404`. **The Offerings path refuses the hub's own plans (#9338 DD5):** right after the grammar parse, a key whose plan begins `ailk-` (`HUB_PLAN_PREFIX`, any period — `ailk-pro`, `ailk-pro-plus` and any hub plan added later, #9968) is the same `404`, before any read or Stripe call, for every Organization but the one whose fulfilment hook mints licences (#9383 DD5, `fulfilmentFor(organizationId)?.mintsLicences`, read with no Stripe call) — anywhere else such a sale would stamp `ailk_offering` with no licence minted for it. Every hub plan's lookup key begins `ailk-` (`ailk-<plan>_<period>`, as `PLAN_PURCHASE_LOOKUP_KEYS` builds them): a hub plan keyed outside that namespace is outside this refusal. The request schema and the `201 { sessionId, url }` body are unchanged, so the `start_purchase` parity row is too. **`GET /v1/checkout/sessions/:id` (A13):** when `DATABASE_URL` is set, a session `readCheckoutSessionRecord` holds is read with that record's Organization's own client (keeping the environment fallback, so a session Start trial recorded on the owner's environment key stays readable), only in the record's mode; its line-item names pass the catalog's T1 `normalizeText`; a record that cannot be read, or a connection gone, unusable or in another mode, is `502`, never the env path; no record is today's env read. Logs carry the workspace or Organization, an 8-character session prefix and a fixed reason only.
|
|
@@ -93,6 +93,8 @@ Routes are versioned under `/v1/*`:
|
|
|
93
93
|
- `/v1/workspaces/:workspaceId/members` (GET) — the org's member roster (`userId`, `name`, `email`, `role`), the Pro `workspace` module's Team tab (#5371). Read-only by design: granting a Membership is the invite flow, which is its own spec with a mandatory security review (§2.3), so no POST/PUT/DELETE is routed at this path. Scoped to `request.tenant.organizationId` — Membership is org-level — server-resolved from the validated session. Role floor: `members-read` (owner, admin — `@working-theory/validation`'s permission table (#7636 D2)): the roster carries every member's email, and who the other members are is not a member's job. No MCP twin — identity-bound tenant route (#3591 exception).
|
|
94
94
|
- `GET /v1/workspaces/:workspaceId/knowledge` — the workspace knowledge read (#9683, spec `docs/specs/9683-agent-session-audit-knowledge-spec.md`; `packages-paid/api-workspace/src/routes/workspaces/knowledge.ts`, `// oss: false`): what the business offers, runs and serves, as `workspaceKnowledgeSchema` — `offerings` (the shown offering set, only while the commerce claim is held), `magnets` (the newest flows page's `live` rows), `meetingTypes` (the enabled slugs minus `privateSlugs`, titled by slug), `sitesServed` (the installed hub's live spokes for this workspace, read only when the hub's own gate answers `claimed`, through the optional `HubRegistry.listLiveSpokesForWorkspace`) and `strategyDocs` (`lib/strategy-docs.ts`, `[]` until a store exists) — every key always present, each entry an id, a title and a summary (`null` when it fails its check; an entry whose id or title fails is skipped and counted). Takes no query. Gate: `allows(people-read) || allows(people-memory-propose)` — a person member holds both; an agent only by grant. Every authenticated call writes one `workspace.knowledge.read` audit row (counts only; an agent's carries its session, thread `workspace:<workspaceId>`); `Cache-Control: private, no-store`. Same registration scope as the routes above (`workspace || multi-tenant`, `tenant:settings`). MCP twin: `get_workspace_knowledge` on `/mcp/workspace`.
|
|
95
95
|
- `/v1/workspaces/:workspaceId/agents` (GET `?userId=`, POST), `/v1/workspaces/:workspaceId/agents/:agentId/key` (POST), `/v1/workspaces/:workspaceId/agents/:agentId` (PATCH `{ enabled }`), `PUT /v1/workspaces/:workspaceId/agents/:agentId/grants` (`{ grants }`, #7864), and `/v1/agent/context` (GET) — the agent seat (#3591, `src/routes/workspaces/agents.ts` over `src/lib/agent-store.ts`, `// oss: false`). An agent is a stored delegate of one person, bound to one workspace. The five management routes refuse an agent caller outright (403), check `agents-manage` (owner, admin) before any read, and scope every read and write to `tenant.organizationId`, never a body or query value; an agent of another organization is the same 404 as a missing one. Create returns 201 with the key shown once; re-key returns the new key once. Each successful create, re-key, switch and grant change writes an `agents.*` audit record. **Grants (#7864, `docs/specs/7864-agent-key-grants-spec.md`).** An agent acts at `member`, plus any of nine permissions an owner or admin granted it one by one — `content-edit`, `content-publish`, `assets-write`, `magnets-manage`, `meeting-types-manage`, `people-read` since #852, `member-pieces-manage` since #7878, and `people-memory-propose` and `inbox-act` since #9679 (`@working-theory/validation`'s `AGENT_GRANTABLE_PERMISSIONS`, held to that set by the `agents_grants_allowed` CHECK). The grant PUT replaces the whole set (an empty list removes every grant) and refuses a grant the agent's person's own role does not hold, `409 grant_exceeds_person`. The clamp is one function both tenant guards call (`services/default-tenant.ts`'s `agentTenant`): an agent holds a stored grant only while its person's role holds it, so a demoted person's agent keeps the row but not the permission. The routes for the nine check `allows(tenant, "<permission>")`, which reads the grants — except that the six existing inbox `inbox-act` checks still read `can(tenant.role, …)` until #9686 moves them, and `people-memory-propose` is read today only by the workspace knowledge read (#9683, beside `people-read`); every other check keeps `can(tenant.role, …)`, so no other owner or admin permission is reachable. Every agent write on content, assets and meeting types writes one `recordAgentWrite` row naming the agent and its person (magnets already did); a person's writes there stay unaudited. The member-area writes audit every caller through `recordAuditAction`, whose actor names an agent as the agent. No grant raises the ceiling. `/v1/agent/context` is the agent's own read, unchanged by grants, and needs no permission beyond the workspace guard. Same registration scope as the routes above (`workspace || multi-tenant`).
|
|
96
|
+
- `GET /v1/workspaces/:workspaceId/clients` and `DELETE /v1/workspaces/:workspaceId/clients/:clientId[?userId=]` — the workspace's Connected clients (#10393, `src/routes/workspaces/clients.ts` over `src/lib/connected-clients-store.ts`, `// oss: false`). The GET lists, as one table, the workspace's live OAuth authorizations (one row per client and person, from the token rows bound to this workspace; `lastUsedAt` is the newest sign-in or refresh) and its enabled agent keys, each row `kind`, `id`, `client.name`, `person`, `scopes`, `lastUsedAt` and never a token or key. A person holding `agents-manage` (owner, admin) sees every row; anyone else, an agent principal included, sees only their own person's. The DELETE refuses every agent caller (403), ends an OAuth authorization by deleting its token rows for this workspace only (a caller without `agents-manage` may name only themselves — another person's row is the same 404 as a missing one) and an agent key by switching it off (`agents-manage`), touches no session, and writes one audit row and one `connected-client-revoked` log line. The GET's MCP twin is `list_connected_clients`; the DELETE has no tool, by decision. Same registration scope as the routes above (`workspace || multi-tenant`).
|
|
97
|
+
- `GET /v1/workspaces/:workspaceId/discovery` and `PUT /v1/workspaces/:workspaceId/discovery` — the owner's discovery switch, whether AI and search tools can find the workspace's site (#10510, `src/routes/workspaces/discovery.ts`, `// oss: false`, over `packages/api/src/services/site-discovery.ts`). Both parse `:workspaceId` with `inboxWorkspaceParamsSchema` (`400 validation_failed`) and act on `request.tenant.workspaceId` only. The GET needs any tenant, an agent included, and answers `{ ok: true, data: { discoverable } }`; the PUT needs `workspace-settings` (owner, admin — an agent is refused, `403`), checked before the strict `{ discoverable: boolean }` body (`400 validation_failed` on anything else), and answers the stored value. The GET's MCP twin is `get_site_discovery`; the PUT has no tool, by decision. Same registration scope as the routes above (`workspace || multi-tenant`, one `tenant:settings` row).
|
|
96
98
|
- `/v1/workspaces/:workspaceId/content` (GET, POST), `/v1/workspaces/:workspaceId/content/:type/:slug` (GET, PATCH), `/v1/workspaces/:workspaceId/content/:type/:slug/{publish,unpublish}` (POST) — the tenant-guarded content editor's write surface (#5853, `src/routes/workspaces/content.ts`): list, create, read/save the draft tip (under `X-Draft-Version`; `If-Match` still accepted for one release, #10264), and publish/unpublish, all over `Artifact`/`ArtifactVersion`. `:type` is validated against the page-type registry (`z.enum(SCHEMA_ORG_TYPES)`) rather than accepted as a free string on every addressed operation — an unregistered type is a 400 before any lookup, since it is echoed back and passed to `notifyRevalidate` as the site's cache tag. Role floors from `@working-theory/validation`'s permission table (#7636 D2): publish and unpublish need `content-publish`, and create, read and save need `content-edit` (both owner, admin — an admin builds, a member operates). The list serves `content-edit` the editor's full view and a caller holding only `docs-read` (a member) a published-only projection (D4): draft rows dropped, every row `status: "published"`, `version: null`, `valid: true`, and a `published_with_draft` page's `type`/`tags` read from its PUBLISHED version — applied before every filter, so a member's `?status=draft` or a draft tip's `?tag=` matches nothing. **The GET list is a UNION of page artifacts and DOCUMENT assets** (#6093, D1/D9 — "one store, two views"): every row carries `tags: string[]` (never null, read from the draft tip's stored content for a page, from `Asset.tags` for a file) and a DERIVED `kind: "page" | "post" | "file"` (`BlogPosting` alone maps to `"post"`; an asset row is always `"file"`) — an optional `?tag=<slug>` filters the union AFTER it is built, so a file is returned under either its own tag or a shared one. The asset half of the union (`DOCUMENT_CONTENT_TYPES`, i.e. `application/pdf`) is skipped entirely when `locale`/`type` is supplied or `status` names anything but `"published"`, since a file row can never satisfy those. A foreign `:workspaceId` costs zero artifact **and** zero asset queries. No MCP twin — identity-bound tenant routes (#3591 exception, same shape as the sibling `/v1/workspaces/*` routes).
|
|
97
99
|
- `/v1/workspaces/:workspaceId/assets` (GET, POST), `/v1/workspaces/:workspaceId/assets/:assetId` (PATCH, DELETE), `/v1/assets/:assetId` (GET, public/anonymous) — the workspace asset store (#5854 upload/read; #6093 list/PATCH/DELETE, `src/routes/assets/{index,upload,read,manage}.ts`). Upload accepts an allowlisted union of three named classes (`src/routes/assets/index.ts`): five RASTER images (`image/png`, `image/jpeg`, `image/gif`, `image/webp`, `image/avif`), one VECTOR image (`image/svg+xml`, admitted under the operator's 2026-09-18 `allow-svg-assets` ruling — issue #6093 comment `5731573595` — superseding #5854's original SVG exclusion) and one DOCUMENT type (`application/pdf`), all under the existing 20 MB cap; `?tags=<slug>[,<slug>…]` stores tags on the row. The public read serves a RASTER type `inline` exactly as before; every OTHER admitted type (SVG, PDF) is served `attachment`, with the filename's extension pinned to the stored content type (closing extension spoofing) and a five-directive `Content-Security-Policy` (`default-src 'none'; sandbox; frame-ancestors 'none'; form-action 'none'; base-uri 'none'`) that makes the bytes never a document on this origin — this is what makes admitting SVG safe with NO server-side sanitizer. `GET …/assets` (`assets-read` — every role, a member included) lists a workspace's assets, metadata only, never `bytes`, with an optional `?tag=<slug>` filter; this is what narrows the store's prior "nothing lists assets" premise to its precise form — no ANONYMOUS and no CROSS-TENANT enumeration, both still true. `PATCH …/assets/:assetId` updates `title`/`description`/`tags` (`assets-write`, owner and admin, the upload's permission too; `null` clears a text field to SQL `NULL`, `""` stores empty; the asset's `id` and public URL are untouched). `DELETE …/assets/:assetId` (`assets-write`) removes the row and the bytes at the ORIGIN only — already-cached copies elsewhere are not recallable under the year-long `immutable` promise, and no reference check protects a page that still embeds the URL; absent and foreign ids both take the same 404. Every tenant route here is identity-bound; no MCP twin (#3591 exception). The public read has no MCP twin either — it serves bytes to an `<img>`/download, the same boundary `upload.ts` records for its own sibling. **A client's file is never in the library's public life (#7877 D18):** the default `GET …/assets` listing leaves out every asset tagged `private` — only `?tag=private` lists them (the Admin's Compose) — and the public read answers its uniform 404 for an asset tagged `private`, attached to any member-area message, or (#8171 D3) named by a delivered piece version (one `pieceVersion.count`, since the tag can be removed after delivery), so such a file's bytes are served only by its owner's member reads.
|
|
98
100
|
- `/v1/subscription/me` (GET) — `?include=billing` (#8171 D7), from a cookie session only, adds the optional `planName` and `paymentMethod` (brand, last four, expiry) read from Stripe through `lib/stripe.ts`'s `readCustomerBillingSummary` for the caller's own resolved customer: cached per customer for 60 s (a failure too), one read in flight per customer, bounded at 3 s; a failure leaves both keys absent, a value outside the schema's bounds is `null`, and no id is served or logged. A bearer token, another value or none answers exactly as before.
|
|
@@ -232,7 +234,9 @@ sweep's own DB constraint (never a caller-side lock).
|
|
|
232
234
|
- Don't bundle `apps/api` (esbuild, rollup, webpack). Slice specifiers cannot survive it — see `## Deploy path`.
|
|
233
235
|
- Don't construct Fastify with `logger: true` or any `logger:` option. Pass `loggerInstance` built from `createLogger` in `@working-theory/observability` (see `packages/api/src/server.ts`). Fastify's own pino carries none of the redaction.
|
|
234
236
|
|
|
235
|
-
Last updated: 2026-
|
|
237
|
+
Last updated: 2026-11-01 — #10510: the public content reads bullet names `GET /v1/content/discovery`, and a bullet for `GET`/`PUT /v1/workspaces/:workspaceId/discovery` — verified against `packages/api/src/routes/content/discovery.ts`, `packages/api/src/services/site-discovery.ts` and `packages-paid/api-workspace/src/routes/workspaces/discovery.ts`.
|
|
238
|
+
|
|
239
|
+
Prior: 2026-10-26 — #8047 (#8241): the member purchases bullet matches a purchase's lead on the caller's verified `User.email`, never the Person's — verified against `packages/api/src/lib/member-store.ts` and `packages-paid/api-workspace/src/routes/member/purchases.ts`.
|
|
236
240
|
|
|
237
241
|
Prior: 2026-10-26 — #8047: the `/v1/hub-sign-in/me` entry names its `billingCustomerId` answer and its first-read Person link, the People bullet names the one `Person.billingCustomerId` writer, Get trial reads the recorded Start-trial session, and `## What NOT to do` gains the Fastify `logger:` rule (#5239 AC-3) — verified against `packages/api/src/services/{person,checkout-buyer}.ts`, `packages/api/src/routes/webhooks/stripe.ts`, `packages-paid/api-hub/src/services/hub-sign-in.ts`, `packages-paid/api-workspace/src/services/trial.ts` and `packages/api/src/server.ts`.
|
|
238
242
|
|
|
@@ -21,8 +21,18 @@ MCP_HTTP_PATH=/mcp
|
|
|
21
21
|
# Public URL of the HTTP transport (used by /.well-known/mcp.json discovery).
|
|
22
22
|
# Set this to the externally-reachable URL of the MCP server.
|
|
23
23
|
# Example: https://mcp.yourdomain.com
|
|
24
|
+
# With BETTER_AUTH_URL below, it also turns on the OAuth sign-in on
|
|
25
|
+
# /mcp/workspace: its origin names the protected resource (#10391).
|
|
24
26
|
MCP_PUBLIC_HTTP_URL=
|
|
25
27
|
|
|
28
|
+
# The site's own origin — the OAuth authorization server MCP clients sign in
|
|
29
|
+
# through (apps/web's BETTER_AUTH_URL, the same value). Set together with
|
|
30
|
+
# MCP_PUBLIC_HTTP_URL, /mcp/workspace accepts the site's access tokens beside
|
|
31
|
+
# agent keys, validating each against the site's token-info lookup. Both must
|
|
32
|
+
# be https: (or http: on localhost); unset, /mcp/workspace takes agent keys only.
|
|
33
|
+
# A spoke has no authorization server of its own: leave it unset there.
|
|
34
|
+
# BETTER_AUTH_URL=
|
|
35
|
+
|
|
26
36
|
# ── Rate limiting ──────────────────────────────────────────────────────────────
|
|
27
37
|
|
|
28
38
|
# Max requests per minute per IP for read tools.
|
|
@@ -85,3 +95,13 @@ MCP_ABUSE_BLOCKED_USER_AGENTS=
|
|
|
85
95
|
|
|
86
96
|
# Required ONLY when CONTENT_ADAPTER=database.
|
|
87
97
|
# DATABASE_URL=
|
|
98
|
+
|
|
99
|
+
# ── Spoke key ─────────────────────────────────────────────────────────────────
|
|
100
|
+
|
|
101
|
+
# SPOKE (server-only). The key the hub's `spokes register` printed. Sent as
|
|
102
|
+
# `x-ailk-spoke-key` on every server-side call to AILK_API_URL, and only when
|
|
103
|
+
# that is https (or loopback). Not the same value as AILK_VISITOR_FORWARD_KEY.
|
|
104
|
+
# On a hub, the hub's own apps/mcp carries the hub site's own key: a keyless
|
|
105
|
+
# call is refused there, so the owner's discovery switch (#10510) cannot be
|
|
106
|
+
# read and the public /mcp stays on.
|
|
107
|
+
# AILK_SPOKE_KEY=
|
|
@@ -57,6 +57,13 @@ now mean `packages/mcp-server/src/<file>`.
|
|
|
57
57
|
instance answers), `false`/`0` stateful (sessions in one process's memory). Unset, the
|
|
58
58
|
Vercel entry is stateless and `src/index.ts` is stateful. Any other value fails the boot.
|
|
59
59
|
The Vercel entry needs stateless: a session kept in one instance is lost on the next.
|
|
60
|
+
- **The owner's discovery switch (#10510 D5).** `httpTransportConfigFromEnv()` sets
|
|
61
|
+
`discoverable: siteDiscoverable` (`@working-theory/api-client`), so while the owner has hidden
|
|
62
|
+
the site, GET, POST and DELETE on the anonymous path answer Fastify's own 404 — an off server
|
|
63
|
+
looks like none — before the abuse guard or any tool runs. It is read per request through the
|
|
64
|
+
reader's 15-second shared answer; a failed read keeps the last answer, then on.
|
|
65
|
+
`/mcp/workspace`, its protected-resource metadata and stdio are untouched. A hub's own tool
|
|
66
|
+
server needs the hub site's `AILK_SPOKE_KEY`, or its hub refuses the read and the server stays on.
|
|
60
67
|
- **Content root.** Every content tool and `audit` take their root from `mcpContentRoot()`
|
|
61
68
|
(`lib/content-adapter.ts`): `AILK_CONTENT_ROOT`, else `<cwd>/content`, else
|
|
62
69
|
`<cwd>/../../content`. Never build a content path from `process.cwd()` in a tool — the one
|
|
@@ -130,9 +137,11 @@ Every tool maps 1:1 to an `apps/api` endpoint. When you change a tool, change th
|
|
|
130
137
|
|
|
131
138
|
- **22 in the `TOOLS` array / parity test**: the `TOOLS` array in
|
|
132
139
|
`packages/metadata/src/mcp-tool-catalog.ts` defines 22 anonymous tools; the
|
|
133
|
-
parity test counts that full array. It also holds the thirty-
|
|
140
|
+
parity test counts that full array. It also holds the thirty-seven signed-in
|
|
134
141
|
workspace tools (#3591; the content-suggestion pair `draft_page_suggestions`
|
|
135
|
-
and `decide_page_suggestion` since #10218
|
|
142
|
+
and `decide_page_suggestion` since #10218; the draft pair `save_draft` and
|
|
143
|
+
`read_report` since #10336; the knowledge-base pair `read_knowledge` and
|
|
144
|
+
`read_knowledge_doc` since #10345), which no anonymous view returns — see § The
|
|
136
145
|
signed-in workspace surface below.
|
|
137
146
|
- **20 registered on a KEYLESS deployment, 21 on a commerce-claimed one,
|
|
138
147
|
while the Free level is held or over HTTP — 21 and 22 over stdio once it is
|
|
@@ -178,12 +187,14 @@ here. Spec: `docs/specs/3591-tenant-scoped-agent-auth-secure-spec.md` D13–D16.
|
|
|
178
187
|
- **apps/mcp checks only the key's shape.** The key is attached as the SDK's `req.auth` for that
|
|
179
188
|
request alone and reaches a tool as `extra.authInfo.token`. apps/api is the one place it is
|
|
180
189
|
checked, and its tenant guard and the caller's clamped role decide every answer.
|
|
181
|
-
- **Thirty-
|
|
190
|
+
- **Thirty-nine tools, each a `twin`.** The catalog rows carry `surface: "workspace"`, `tier: "pro"`
|
|
182
191
|
and `modulesAnyOf` (the modules any one of which registers the tool — its twin route's gate):
|
|
183
192
|
`get_agent_context`, (#9683) `get_workspace_knowledge` — twin of `GET
|
|
184
193
|
/v1/workspaces/:workspaceId/knowledge`: the business's shown offerings, live magnets, listed
|
|
185
194
|
meeting types, sites served and strategy documents, read with the `people-read` or
|
|
186
|
-
`people-memory-propose` grant —
|
|
195
|
+
`people-memory-propose` grant — (#10393) `list_connected_clients` — twin of `GET
|
|
196
|
+
/v1/workspaces/:workspaceId/clients`: the AI clients and agent keys connected to the workspace,
|
|
197
|
+
only the caller's own person's for an agent; revoking has no tool — the five message tools (#9686 added `list_inbox_threads`, twin of
|
|
187
198
|
`GET …/messages/threads`: the inbox's threads, 25 to a page with a count per view; Done now
|
|
188
199
|
archives the message's thread and accept no longer marks it done, and an agent reaches any
|
|
189
200
|
inbox tool only with the `inbox-act` grant), the three submission tools, the three
|
|
@@ -198,18 +209,35 @@ here. Spec: `docs/specs/3591-tenant-scoped-agent-auth-secure-spec.md` D13–D16.
|
|
|
198
209
|
tools, `list_proposals` and `create_proposal`, twinning `GET`/`POST
|
|
199
210
|
/v1/workspaces/:workspaceId/people/:personId/proposals` with the same grant: a proposal is
|
|
200
211
|
private to one client, `payments` says whether approval leads to a Stripe page, and an agent's
|
|
201
|
-
price is refused `proposal_needs_person` (a person prices) — and (#9681) the four person-thread tools, `get_person_thread`, `set_person_thread_state`, `list_external_threads` and `add_external_thread`, twinning `GET`/`PATCH …/people/:personId/thread` and `GET`/`POST …/people/:personId/external-threads`: a client's whole thread page by page with its read/archived/resolved state, and pointers to email and Slack threads (never message text); reading needs the `people-read` grant, writing also `inbox-act`, and the client erasure route has no tool, by decision — and (#9682) the three person-memory tools, `list_person_memories`, `get_person_memory` and `propose_person_memory`, twinning `GET …/people/:personId/memories`, `GET …/memories/:path` and `POST …/memories`: an agent reads only live, unexpired memories (confirmed by a person, read within 180 days) and its proposal is never live — a new path is a draft, a confirmed one gets a pending version — needing the `people-read` or `people-memory-propose` grant to read and `people-memory-propose` to propose; the six person-only memory routes (edit, delete, confirm, redact, the versions list, restore) have no tool, by decision — and (#9749) `get_slack_connection_status`, twin of `GET /v1/workspaces/:workspaceId/slack-connection` (registered with `workspace`/`multi-tenant`, its route's `TENANCY` row: whether the Organization's Slack is connected, its team name and the date, never a credential; connect and disconnect are a person's act and have no tool, by decision), and `get_prospect_channel_settings` / `set_prospect_channel_settings`, twins of `GET`/`PUT …/prospect-channel` (registered with `scheduling`, their routes' module): the update replaces every field, needs the `meeting-types-manage` grant, is refused `slack_not_connected` while the Organization has no Slack connection, and an agent may change only `enabled`, `channelPrefix` and `archiveDays` — a change to `memberIds` or `welcomeTemplate` is refused `prospect_channel_needs_person
|
|
212
|
+
price is refused `proposal_needs_person` (a person prices) — and (#9681) the four person-thread tools, `get_person_thread`, `set_person_thread_state`, `list_external_threads` and `add_external_thread`, twinning `GET`/`PATCH …/people/:personId/thread` and `GET`/`POST …/people/:personId/external-threads`: a client's whole thread page by page with its read/archived/resolved state, and pointers to email and Slack threads (never message text); reading needs the `people-read` grant, writing also `inbox-act`, and the client erasure route has no tool, by decision — and (#9682) the three person-memory tools, `list_person_memories`, `get_person_memory` and `propose_person_memory`, twinning `GET …/people/:personId/memories`, `GET …/memories/:path` and `POST …/memories`: an agent reads only live, unexpired memories (confirmed by a person, read within 180 days) and its proposal is never live — a new path is a draft, a confirmed one gets a pending version — needing the `people-read` or `people-memory-propose` grant to read and `people-memory-propose` to propose; the six person-only memory routes (edit, delete, confirm, redact, the versions list, restore) have no tool, by decision — and (#9749) `get_slack_connection_status`, twin of `GET /v1/workspaces/:workspaceId/slack-connection` (registered with `workspace`/`multi-tenant`, its route's `TENANCY` row: whether the Organization's Slack is connected, its team name and the date, never a credential; connect and disconnect are a person's act and have no tool, by decision), and `get_prospect_channel_settings` / `set_prospect_channel_settings`, twins of `GET`/`PUT …/prospect-channel` (registered with `scheduling`, their routes' module): the update replaces every field, needs the `meeting-types-manage` grant, is refused `slack_not_connected` while the Organization has no Slack connection, and an agent may change only `enabled`, `channelPrefix` and `archiveDays` — a change to `memberIds` or `welcomeTemplate` is refused `prospect_channel_needs_person` — and (#10218) the content-suggestion pair, `draft_page_suggestions` and `decide_page_suggestion` — and (#10336) the draft pair, `save_draft` and `read_report`, twinning `PATCH`/`GET …/content/:type/:slug` with the `content-edit` grant: `save_draft` takes the page's `content` whole as the PATCH body and its `version` as `X-Draft-Version`, writes a draft version that never goes live, and answers 409 `conflict` with `currentVersion` when the draft moved; `read_report` returns the draft tip, the version a save must send, its open suggestions and the route's check report (`ready`, `needs repair` or `needs your words`), computed by the route and never by the tool — and (#10661) `publish_draft`, twinning `POST …/content/:type/:slug/publish` with the `content-publish` grant: it publishes the draft tip (or a named version) exactly as a person holding that permission does, an agent without the grant is refused 403 `agent_refused` before anything is read, and it never widens Audience or re-enables a doc — and (#10345) the knowledge-base pair, `read_knowledge` and `read_knowledge_doc`, twinning `GET …/knowledge-base` and `GET …/knowledge-base/:type/:slug` with no grant (`docs-read` is every role's): the docs a named member may read (`userId`, by default the agent's own person), at the lower of the agent's person's role and that member's, so naming someone only narrows; Live does not gate them and a disabled doc never appears; `filter: "workspace-memory"` answers the always-loaded set with its `totalTokens` and an 8000-token `warning` — and (#10733) the five Language tools, `list_language_items`, `get_language_item`, `save_language_item`, `set_language_item_live` and `list_language_item_pages`, twinning `GET …/language`, `GET`/`PUT …/language/:kind/:slug`, `POST …/language/:kind/:slug/live` and `GET …/language/:kind/:slug/appears-on` (`mcp-workspace`'s `language.ts`): a workspace's glossary terms, FAQs and keywords; reading needs no grant, saving the `content-edit` grant (`version: null` creates, a tip id updates, 409 `slug_taken` / `stale` otherwise) and the Live switch `content-publish` with the `version` the person reviewed; tags are the hub's and never written by a tool; a keyword is vocabulary and is never shown to visitors; the delete route is a person's write (FEAT-101 Disable) and has no tool, by decision. No workspace tool widens Audience or re-enables a doc, and only `publish_draft` and `set_language_item_live`, with the `content-publish` grant, set Live — the access and Language delete routes answer every agent 403 `agent_refused`, and the publish and Live routes an agent without the grant, which reaches a tool as the code and status. Each takes its route's params, query and body and returns the route's
|
|
202
213
|
`data`. They are in the parity guard like every other tool, and never in
|
|
203
214
|
`/.well-known/mcp.json`.
|
|
215
|
+
- **The OAuth bearer branch (#10391).** When `BETTER_AUTH_URL` (the site's origin, its OAuth
|
|
216
|
+
2.1 authorization server, #10357) and `MCP_PUBLIC_HTTP_URL` are both `https:` (or `http:` on a
|
|
217
|
+
loopback host), `/mcp/workspace` also accepts the site's access token — only the plugin's
|
|
218
|
+
shape, `Bearer` + 32 ASCII letters, so an arbitrary string is 401 with no lookup. Each request
|
|
219
|
+
is validated by one `GET <BETTER_AUTH_URL>/api/auth/mcp/token-info` (`oauth-bearer.ts`: 5 s
|
|
220
|
+
timeout, no redirect followed, nothing cached — a revoked token fails its next call). A valid
|
|
221
|
+
token reaches tools as `AuthInfo` (`clientId`, the `ailk:*` scopes the live grants still name,
|
|
222
|
+
`expiresAt`, `resource`, and `extra: { kind: "oauth", userId, workspaceId, actingRole }`), its
|
|
223
|
+
`token` forwarded to apps/api like a key; a `tools/call` whose `arguments.workspaceId` is not
|
|
224
|
+
the token's workspace is 401 before any tool runs. While the branch is on, every 401 carries
|
|
225
|
+
`WWW-Authenticate: Bearer [error="invalid_token", ]resource_metadata="…"`, the RFC 9728
|
|
226
|
+
document is served at `/.well-known/oauth-protected-resource${path}/workspace` (no root
|
|
227
|
+
document), and an authorization server that cannot answer is `503 authorization_unavailable`
|
|
228
|
+
with `Retry-After: 5` — never a pass and never a 401. Issuer and resource come from env only,
|
|
229
|
+
never a request's `Host`. Either name unset, the route takes agent keys only, exactly as before;
|
|
230
|
+
a spoke has no authorization server, so it leaves `BETTER_AUTH_URL` unset. `/mcp` is untouched.
|
|
204
231
|
- **Handlers forward, nothing else.** `workspace-server.ts` resolves each tool from
|
|
205
232
|
`@working-theory/mcp-workspace`'s `workspaceTools` (`agent_context.ts`, `knowledge.ts`, `inbox_messages.ts`,
|
|
206
|
-
`inbox_submissions.ts`, `inbox_applications.ts`, `people_list.ts`, `person_notes.ts`, `person_thread.ts`, `person_memory.ts`, `proposals.ts`, `slack_connection.ts`, all
|
|
233
|
+
`inbox_submissions.ts`, `inbox_applications.ts`, `people_list.ts`, `person_notes.ts`, `person_thread.ts`, `person_memory.ts`, `proposals.ts`, `slack_connection.ts`, `page_suggestions.ts`, `content_draft.ts`, `content_publish.ts`, `knowledge_base.ts`, all
|
|
207
234
|
`// oss: false`) and, for the two prospect-channel settings tools, `@working-theory/mcp-scheduling`'s
|
|
208
235
|
`workspaceTools` (`prospect_channel_settings.ts`, #9749 — the pack the scheduling module carries, so a
|
|
209
236
|
scheduling-only build serves what it lists), loaded through the site's generated registry, as `server.ts` resolves the
|
|
210
237
|
module-gated tools from their packs (#9460). Each calls `@working-theory/api-client`'s `inbox` surface
|
|
211
238
|
(the people-list, notes, person-thread, person-memory and proposals tools, its `people` surface; the
|
|
212
|
-
Slack status and prospect-channel tools, its `slack` surface
|
|
239
|
+
Slack status and prospect-channel tools, its `slack` surface; the suggestion and draft tools, its
|
|
240
|
+
`pageSuggestions` and `contentDrafts` surfaces) with the key. A refusal from the
|
|
213
241
|
route is an MCP tool error naming the route's `error.code` and HTTP status only.
|
|
214
242
|
- **Never log or return the key.** Don't log or serialise `extra`, `extra.authInfo` or
|
|
215
243
|
`extra.requestInfo` — the SDK puts the raw `authorization` header there.
|
|
@@ -285,7 +313,17 @@ Example (lead routing):
|
|
|
285
313
|
- Don't add a tool without the matching `apps/api` endpoint.
|
|
286
314
|
- Don't put business logic in tool handlers — they're adapters.
|
|
287
315
|
|
|
288
|
-
Last updated: 2026-
|
|
316
|
+
Last updated: 2026-11-02 — #10733 (D7, D8): the five Language twins (`mcp-workspace`'s `language.ts`) twin the Language collection's list, read, save, Live and appears-on routes; the person-only delete has no tool — the signed-in workspace surface gains five tools — verified against `packages/metadata/src/mcp-tool-catalog.ts`, `packages-paid/mcp-workspace/src/index.ts`, `packages-paid/api-workspace/src/routes/workspaces/language.ts` and `tests/parity/api-parity/kinds/site.ts`.
|
|
317
|
+
|
|
318
|
+
Prior: 2026-11-02 — #10661 (D1, D5–D8): `publish_draft` (`mcp-workspace`'s `content_publish.ts`) twins the publish route for an agent holding `content-publish`, whose blanket agent refusal narrows to agents without the grant — the signed-in workspace surface is thirty-nine tools — verified against `packages/metadata/src/mcp-tool-catalog.ts`, `packages-paid/mcp-workspace/src/index.ts`, `packages-paid/api-content-write/src/routes/workspaces/content.ts` and `tests/parity/api-parity/kinds/site.ts`.
|
|
319
|
+
|
|
320
|
+
Prior: 2026-10-31 — #10391 (D2–D6): `/mcp/workspace` gains the OAuth bearer branch beside the agent key — one `token-info` lookup per request, the `WWW-Authenticate` challenge and the RFC 9728 protected-resource document, on only with `BETTER_AUTH_URL` and `MCP_PUBLIC_HTTP_URL` — verified against `packages/mcp-server/src/{http-transport,oauth-bearer}.ts`.
|
|
321
|
+
|
|
322
|
+
Prior: 2026-10-31 — #10345 (D9, D12): `read_knowledge` and `read_knowledge_doc` (`mcp-workspace`'s `knowledge_base.ts`) twin the per-user knowledge-base read — the signed-in workspace surface is thirty-seven tools — verified against `packages/metadata/src/mcp-tool-catalog.ts`, `packages-paid/mcp-workspace/src/index.ts` and `tests/parity/api-parity/kinds/site.ts`.
|
|
323
|
+
|
|
324
|
+
Prior: 2026-10-31 — #10336 (D1, D2, D10): `save_draft` and `read_report` (`mcp-workspace`'s `content_draft.ts`) twin the content-write draft save and read — the signed-in workspace surface is thirty-five tools, and the guard's `wire` row option binds the save's body and header — verified against `packages/metadata/src/mcp-tool-catalog.ts`, `packages-paid/mcp-workspace/src/index.ts` and `tests/parity/api-parity/kinds/site.ts`.
|
|
325
|
+
|
|
326
|
+
Prior: 2026-10-29 — #10202 (D8): `get_redirect` (`mcp-workspace`'s `redirect.ts`, the pack's first anonymous handler) twins `GET /v1/redirects` under `module: "content-write"` and `feature: "workspace"` — 22 in `TOOLS`, 20 registered keyless and 21 commerce-claimed while Free is held, seven `tier: "pro"` — verified against `packages/metadata/src/mcp-tool-catalog.ts`, `packages-paid/mcp-workspace/src/index.ts` and `tests/parity/api-parity/kinds/site.ts`.
|
|
289
327
|
|
|
290
328
|
Prior: 2026-10-26 — #9965 (D12, D13): `create_project` is served over stdio only — the HTTP transport, the default, never registers it, and the web manifest always drops it — and `trustOneHop` trusts its hop only on Vercel — verified against `packages/mcp-server/src/{server,run,serverless-app}.ts` and `apps/web/app/.well-known/mcp.json/route.ts`.
|
|
291
329
|
|
|
@@ -17,8 +17,10 @@ CONTENT_ADAPTER=mdx
|
|
|
17
17
|
# Override the on-disk content root. Defaults to <repo-root>/content.
|
|
18
18
|
# AILK_CONTENT_ROOT=
|
|
19
19
|
|
|
20
|
-
# The
|
|
21
|
-
#
|
|
20
|
+
# The secret /api/revalidate verifies apps/api's publish webhook against
|
|
21
|
+
# (#5877). On a site a hub serves (a spoke), the value `spokes revalidate-url`
|
|
22
|
+
# printed for it — NEVER the hub's own secret (#10593). Otherwise, the SAME
|
|
23
|
+
# value as the API's CONTENT_REVALIDATE_SECRET.
|
|
22
24
|
# Needed when CONTENT_ADAPTER=database. Unset means the route returns 503 and
|
|
23
25
|
# the site is not push-revalidated: a publish changes nothing user-visible
|
|
24
26
|
# until a rebuild.
|