create-ailk 0.29.1 → 0.30.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/add-route.js +1 -0
- package/dist/cli.js +15 -1
- package/dist/lib/env-catalog.js +1 -1
- package/dist/lib/scaffold-render-sweep.d.ts +1 -1
- package/dist/lib/scaffold-render-sweep.js +1 -1
- package/dist/module-architecture.js +6 -1
- package/dist/provision-env.d.ts +28 -0
- package/dist/provision-env.js +210 -0
- package/dist/surfaces.js +3 -0
- package/dist/topology.js +4 -0
- package/package.json +3 -3
- package/templates/.env.example +7 -0
- package/templates/apps/api/CLAUDE.md +6 -2
- package/templates/apps/mcp/.env.example +20 -0
- package/templates/apps/mcp/CLAUDE.md +42 -8
- package/templates/apps/web/CLAUDE.md +24 -1
- 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]/about/__tests__/about-routes.test.tsx +2 -2
- 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 +10 -0
- 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 +10 -0
- package/templates/apps/web/auth.ts +184 -7
- package/templates/apps/web/lib/__tests__/auth-routes.test.ts +2 -1
- package/templates/apps/web/lib/auth-routes.ts +5 -3
- package/templates/apps/web/lib/mcp-oauth.ts +702 -0
- package/templates/apps/web/messages/en.json +42 -13
- package/templates/database/seed.ts +6 -0
- package/versions.json +48 -48
package/dist/add-route.js
CHANGED
package/dist/cli.js
CHANGED
|
@@ -27,6 +27,8 @@
|
|
|
27
27
|
* print the report, exit (1 iff routes are missing in check mode)
|
|
28
28
|
* on a `license set` subcommand: write the key into the scaffold, push it
|
|
29
29
|
* to a linked Vercel project and redeploy, exit (#6683 D9)
|
|
30
|
+
* on a `provision-env` subcommand: set MCP_PUBLIC_HTTP_URL (and apps/mcp's
|
|
31
|
+
* BETTER_AUTH_URL) on the linked Vercel projects, exit (#10392 D9)
|
|
30
32
|
* on an `apply-design` subcommand: rewrite an existing site's look,
|
|
31
33
|
* print the changed-file report, exit (#6819)
|
|
32
34
|
* on an `update` subcommand: bring template fixes into a site's
|
|
@@ -76,6 +78,7 @@ import { LICENSE_SET_HELP, runLicenseSet } from "./license-set.js";
|
|
|
76
78
|
import { MIGRATE_CONTENT_HELP, MigrateContentRefusedError, migrateContent, parseMigrateContentArgs, } from "./migrate-content.js";
|
|
77
79
|
import { PLUGIN_INSTALL_LINES, ParseError, helpText, parseCliArgs, parseModules, resolveCliShape, toScaffoldOptions, } from "./parse-args.js";
|
|
78
80
|
import { addModule, removeModule, formatSurfaceDrift, scaffold, syncArchitectureSync, syncCatalogSync, syncClaudeMdSync, syncRoutesSync, } from "./programmatic.js";
|
|
81
|
+
import { PROVISION_ENV_HELP, runProvisionEnv } from "./provision-env.js";
|
|
79
82
|
import { REMOVE_MODULE_HELP, RemoveModuleRefusedError, formatRemoveModuleReport, parseRemoveModuleArgs, } from "./remove-module.js";
|
|
80
83
|
import { SEED_FLOWS_HELP, SeedFlowsRefusedError, parseSeedFlowsArgs, seedFlows, } from "./seed-flows.js";
|
|
81
84
|
import { SEED_WORKSPACE_HELP, SeedWorkspaceRefusedError, parseSeedWorkspaceArgs, runScaffoldSeed, seedWorkspace, } from "./seed-workspace.js";
|
|
@@ -374,6 +377,15 @@ async function main() {
|
|
|
374
377
|
readStdin: () => readFileSync(0, "utf8"),
|
|
375
378
|
}));
|
|
376
379
|
}
|
|
380
|
+
// `provision-env --web-origin <o> --mcp-origin <o> [dir]` (#10392 D9): its
|
|
381
|
+
// own argv shape, beside `license set`. Values reach `vercel` on stdin only.
|
|
382
|
+
if (argv[0] === "provision-env") {
|
|
383
|
+
process.exit(runProvisionEnv(argv.slice(1), {
|
|
384
|
+
cwd: process.cwd(),
|
|
385
|
+
stdout: (text) => process.stdout.write(text),
|
|
386
|
+
stderr: (text) => process.stderr.write(text),
|
|
387
|
+
}));
|
|
388
|
+
}
|
|
377
389
|
// `apply-design <name|path> [--target <dir>]` (#6819): rewrite an existing
|
|
378
390
|
// site's look only. Every refusal — the licence gate included — reaches
|
|
379
391
|
// the top-level catch, exit 1, as a scaffold-time design refusal does.
|
|
@@ -476,7 +488,9 @@ async function main() {
|
|
|
476
488
|
"\n" +
|
|
477
489
|
UPDATE_HELP +
|
|
478
490
|
"\n" +
|
|
479
|
-
LICENSE_SET_HELP
|
|
491
|
+
LICENSE_SET_HELP +
|
|
492
|
+
"\n" +
|
|
493
|
+
PROVISION_ENV_HELP);
|
|
480
494
|
process.exit(0);
|
|
481
495
|
}
|
|
482
496
|
if (opts.version) {
|
package/dist/lib/env-catalog.js
CHANGED
|
@@ -164,7 +164,7 @@ export const ENV_CATALOG = {
|
|
|
164
164
|
secret: false,
|
|
165
165
|
},
|
|
166
166
|
MCP_PUBLIC_HTTP_URL: {
|
|
167
|
-
description: "The public URL of the MCP HTTP transport, advertised by /.well-known/mcp.json.",
|
|
167
|
+
description: "The public URL of the MCP HTTP transport (apps/mcp's `/mcp`), advertised by /.well-known/mcp.json; with BETTER_AUTH_URL it turns on the OAuth metadata. Set by create-ailk provision-env.",
|
|
168
168
|
secret: false,
|
|
169
169
|
},
|
|
170
170
|
MCP_TRANSPORT: {
|
|
@@ -143,7 +143,7 @@ export declare function assessConfigGatedRoute(input: {
|
|
|
143
143
|
* The environment the sitemap sweep's server runs under (#8501): `env` with
|
|
144
144
|
* every `CONFIG_GATED_ROUTES` variable removed.
|
|
145
145
|
*
|
|
146
|
-
* `/sitemap.xml` is
|
|
146
|
+
* `/sitemap.xml` is rendered on request, from the content seeds, so it
|
|
147
147
|
* describes the scaffold AS GENERATED — and a config-gated route's seeds are
|
|
148
148
|
* served only while its variable is unset (the Free placeholder hand-off,
|
|
149
149
|
* #6942). Set it and the route serves its live module page instead: `/meet`
|
|
@@ -285,7 +285,7 @@ function headingTexts(html) {
|
|
|
285
285
|
* The environment the sitemap sweep's server runs under (#8501): `env` with
|
|
286
286
|
* every `CONFIG_GATED_ROUTES` variable removed.
|
|
287
287
|
*
|
|
288
|
-
* `/sitemap.xml` is
|
|
288
|
+
* `/sitemap.xml` is rendered on request, from the content seeds, so it
|
|
289
289
|
* describes the scaffold AS GENERATED — and a config-gated route's seeds are
|
|
290
290
|
* served only while its variable is unset (the Free placeholder hand-off,
|
|
291
291
|
* #6942). Set it and the route serves its live module page instead: `/meet`
|
|
@@ -1810,7 +1810,11 @@ export function coOwnedModuleDestinations() {
|
|
|
1810
1810
|
// `review-surface-registration.test.ts` and
|
|
1811
1811
|
// `workspace-module-registration.test.ts` (each a matrix over module
|
|
1812
1812
|
// sets), and mcp's `parity.test.ts` (counts the Server Actions and
|
|
1813
|
-
// tool handlers of the FULL tree, which a stripped scaffold lacks)
|
|
1813
|
+
// tool handlers of the FULL tree, which a stripped scaffold lacks),
|
|
1814
|
+
// and mcp's `workspace-oauth.test.ts` (#10482 — its AC-8 cases call
|
|
1815
|
+
// `get_agent_context` on `/mcp/workspace`, a tool only the
|
|
1816
|
+
// `@working-theory/mcp-workspace` pack ships, and the `workspace`,
|
|
1817
|
+
// `multi-tenant` and `signal-collectors` slices each carry that pack).
|
|
1814
1818
|
// The exclusion is per file, not a `testPathIgnorePatterns` entry in the
|
|
1815
1819
|
// scaffold's jest config: that would hide these suites from a keyed
|
|
1816
1820
|
// scaffold too, and from the suites a slice owns.
|
|
@@ -1853,6 +1857,7 @@ export const EXCLUDE_TEMPLATE_DIRS = [
|
|
|
1853
1857
|
"apps/api/src/__tests__/hub-install.test.ts",
|
|
1854
1858
|
"apps/mcp/__tests__/workspace-surface.test.ts",
|
|
1855
1859
|
"apps/mcp/__tests__/parity.test.ts",
|
|
1860
|
+
"apps/mcp/__tests__/workspace-oauth.test.ts",
|
|
1856
1861
|
"apps/web/app/[locale]/pricing/__tests__/monorepo-only.test.tsx",
|
|
1857
1862
|
"apps/web/app/[locale]/pricing/__tests__/__snapshots__/monorepo-only.test.tsx.snap",
|
|
1858
1863
|
"apps/web/lib/__tests__/hub-children.test.tsx",
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { type LicenseSetRunner } from "./license-set.js";
|
|
2
|
+
/** The production variables each deployed app needs (#10392 D9). */
|
|
3
|
+
export declare function provisionEnv(opts: {
|
|
4
|
+
webOrigin: string;
|
|
5
|
+
mcpOrigin: string;
|
|
6
|
+
}): {
|
|
7
|
+
web: {
|
|
8
|
+
MCP_PUBLIC_HTTP_URL: string;
|
|
9
|
+
};
|
|
10
|
+
mcp: {
|
|
11
|
+
MCP_PUBLIC_HTTP_URL: string;
|
|
12
|
+
BETTER_AUTH_URL: string;
|
|
13
|
+
};
|
|
14
|
+
};
|
|
15
|
+
export interface ProvisionEnvIo {
|
|
16
|
+
/** Resolves a relative or absent `[dir]`. */
|
|
17
|
+
cwd: string;
|
|
18
|
+
stdout: (text: string) => void;
|
|
19
|
+
stderr: (text: string) => void;
|
|
20
|
+
/** Defaults to `license set`'s runner: `vercel`, never through a shell. */
|
|
21
|
+
runner?: LicenseSetRunner;
|
|
22
|
+
}
|
|
23
|
+
export declare const PROVISION_ENV_HELP = "create-ailk provision-env \u2014 set a deployed site's MCP discovery and OAuth variables.\n\nUsage:\n pnpm create ailk@latest provision-env --web-origin <origin> --mcp-origin <origin> [<target-dir>]\n\nArguments:\n --web-origin <origin> The site's public origin (apps/web), e.g. https://example.com\n --mcp-origin <origin> The MCP server's public origin (apps/mcp), e.g. https://mcp.example.com\n <target-dir> An already-scaffolded repo (contains\n architecture.yaml). Defaults to the current dir.\n\nSets MCP_PUBLIC_HTTP_URL on apps/web and apps/mcp, and BETTER_AUTH_URL on\napps/mcp, in each app's linked Vercel production environment. The variables\npersist across deploys. An app with no Vercel link gets the values printed to\nset on your host yourself. Re-run it whenever an origin changes.\n";
|
|
24
|
+
/**
|
|
25
|
+
* Run `provision-env` with `argv` = everything after the `provision-env` word.
|
|
26
|
+
* Returns the process exit code; never throws for an expected failure.
|
|
27
|
+
*/
|
|
28
|
+
export declare function runProvisionEnv(argv: readonly string[], io: ProvisionEnvIo): number;
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `create-ailk provision-env --web-origin <o> --mcp-origin <o> [dir]` — set the
|
|
3
|
+
* production variables a deployed site needs for MCP discovery and OAuth
|
|
4
|
+
* (#10392 D9).
|
|
5
|
+
*
|
|
6
|
+
* `/.well-known/mcp.json` advertises the MCP HTTP endpoint only when
|
|
7
|
+
* `MCP_PUBLIC_HTTP_URL` is set (#7396), and names the OAuth metadata only when
|
|
8
|
+
* `BETTER_AUTH_URL` is set beside it (D8); apps/mcp turns its OAuth branch on
|
|
9
|
+
* only with both (#10391). This step derives every value from the two origins
|
|
10
|
+
* and puts them on each Vercel-linked app's production environment, so a
|
|
11
|
+
* deployment advertises its MCP endpoint without hand configuration:
|
|
12
|
+
*
|
|
13
|
+
* apps/web MCP_PUBLIC_HTTP_URL = <mcp-origin>/mcp
|
|
14
|
+
* apps/mcp MCP_PUBLIC_HTTP_URL = <mcp-origin>/mcp
|
|
15
|
+
* BETTER_AUTH_URL = <web-origin>
|
|
16
|
+
*
|
|
17
|
+
* 1. The target (default: cwd) must contain `architecture.yaml` — the
|
|
18
|
+
* `license set` precondition — else exit 1 with nothing run.
|
|
19
|
+
* 2. For each of `<dir>/apps/web` and `<dir>/apps/mcp` holding
|
|
20
|
+
* `.vercel/project.json`: print its `projectId`, then per variable
|
|
21
|
+
* `vercel env rm <NAME> production --yes` (failure tolerated: it may not
|
|
22
|
+
* exist yet) and `vercel env add <NAME> production`, the value on STDIN.
|
|
23
|
+
* No redeploy: the variables persist, and the next deploy reads them.
|
|
24
|
+
* 3. An app with no link → its `NAME=value` lines are printed, to set by
|
|
25
|
+
* hand. `vercel` missing → exit 1 with the manual commands. A failed
|
|
26
|
+
* `env add` → exit 1 naming the variable now unset, as `license set` does.
|
|
27
|
+
*
|
|
28
|
+
* A value is never an argv element of a spawned process. Local `.env.local`
|
|
29
|
+
* files are deliberately not given an MCP URL: locally apps/mcp defaults to
|
|
30
|
+
* stdio, so a loopback URL would advertise an endpoint nothing serves.
|
|
31
|
+
*
|
|
32
|
+
* Pure module: output and the process runner are injected, so jest drives it.
|
|
33
|
+
*/
|
|
34
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
35
|
+
import { join, resolve } from "node:path";
|
|
36
|
+
import { defaultRunner } from "./license-set.js";
|
|
37
|
+
/** apps/mcp's default `MCP_HTTP_PATH`. */
|
|
38
|
+
const MCP_PATH = "/mcp";
|
|
39
|
+
const LOOPBACK_HOSTS = ["localhost", "127.0.0.1", "[::1]"];
|
|
40
|
+
/** The production variables each deployed app needs (#10392 D9). */
|
|
41
|
+
export function provisionEnv(opts) {
|
|
42
|
+
const webOrigin = assertOrigin(opts.webOrigin, "--web-origin");
|
|
43
|
+
const mcpOrigin = assertOrigin(opts.mcpOrigin, "--mcp-origin");
|
|
44
|
+
const mcpUrl = `${mcpOrigin}${MCP_PATH}`;
|
|
45
|
+
return {
|
|
46
|
+
web: { MCP_PUBLIC_HTTP_URL: mcpUrl },
|
|
47
|
+
mcp: { MCP_PUBLIC_HTTP_URL: mcpUrl, BETTER_AUTH_URL: webOrigin },
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* The origin `value` names: an absolute `https:` URL, or `http:` on a loopback
|
|
52
|
+
* host, with no path, query or fragment. Throws naming the flag otherwise.
|
|
53
|
+
*/
|
|
54
|
+
function assertOrigin(value, flag) {
|
|
55
|
+
let url;
|
|
56
|
+
try {
|
|
57
|
+
url = new URL(value);
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
throw new ProvisionEnvError(`${flag} must be an absolute URL, e.g. https://example.com`);
|
|
61
|
+
}
|
|
62
|
+
const secure = url.protocol === "https:" ||
|
|
63
|
+
(url.protocol === "http:" && LOOPBACK_HOSTS.includes(url.hostname));
|
|
64
|
+
if (!secure) {
|
|
65
|
+
throw new ProvisionEnvError(`${flag} must be https (http is allowed only on localhost)`);
|
|
66
|
+
}
|
|
67
|
+
// A host name only: letters, digits, dots and hyphens (or a bracketed IPv6
|
|
68
|
+
// loopback). The URL parser keeps shell characters in a host, and the value
|
|
69
|
+
// is printed in a copyable manual command.
|
|
70
|
+
if (!/^(?:[A-Za-z0-9.-]+|\[::1\])$/.test(url.hostname)) {
|
|
71
|
+
throw new ProvisionEnvError(`${flag} must name a plain host (letters, digits, dots, hyphens)`);
|
|
72
|
+
}
|
|
73
|
+
if (url.pathname !== "/" || url.search !== "" || url.hash !== "" || url.username !== "") {
|
|
74
|
+
throw new ProvisionEnvError(`${flag} must be an origin only — no path, query or fragment`);
|
|
75
|
+
}
|
|
76
|
+
return url.origin;
|
|
77
|
+
}
|
|
78
|
+
class ProvisionEnvError extends Error {
|
|
79
|
+
}
|
|
80
|
+
export const PROVISION_ENV_HELP = `create-ailk provision-env — set a deployed site's MCP discovery and OAuth variables.
|
|
81
|
+
|
|
82
|
+
Usage:
|
|
83
|
+
pnpm create ailk@latest provision-env --web-origin <origin> --mcp-origin <origin> [<target-dir>]
|
|
84
|
+
|
|
85
|
+
Arguments:
|
|
86
|
+
--web-origin <origin> The site's public origin (apps/web), e.g. https://example.com
|
|
87
|
+
--mcp-origin <origin> The MCP server's public origin (apps/mcp), e.g. https://mcp.example.com
|
|
88
|
+
<target-dir> An already-scaffolded repo (contains
|
|
89
|
+
architecture.yaml). Defaults to the current dir.
|
|
90
|
+
|
|
91
|
+
Sets MCP_PUBLIC_HTTP_URL on apps/web and apps/mcp, and BETTER_AUTH_URL on
|
|
92
|
+
apps/mcp, in each app's linked Vercel production environment. The variables
|
|
93
|
+
persist across deploys. An app with no Vercel link gets the values printed to
|
|
94
|
+
set on your host yourself. Re-run it whenever an origin changes.
|
|
95
|
+
`;
|
|
96
|
+
const APPS = [
|
|
97
|
+
{ dir: join("apps", "web"), key: "web" },
|
|
98
|
+
{ dir: join("apps", "mcp"), key: "mcp" },
|
|
99
|
+
];
|
|
100
|
+
function parseProvisionEnvArgs(argv) {
|
|
101
|
+
const parsed = {
|
|
102
|
+
help: false,
|
|
103
|
+
webOrigin: undefined,
|
|
104
|
+
mcpOrigin: undefined,
|
|
105
|
+
target: undefined,
|
|
106
|
+
};
|
|
107
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
108
|
+
const arg = argv[i];
|
|
109
|
+
if (arg === "--help" || arg === "-h")
|
|
110
|
+
parsed.help = true;
|
|
111
|
+
else if (arg === "--web-origin")
|
|
112
|
+
parsed.webOrigin = argv[++i];
|
|
113
|
+
else if (arg === "--mcp-origin")
|
|
114
|
+
parsed.mcpOrigin = argv[++i];
|
|
115
|
+
else if (arg.startsWith("-"))
|
|
116
|
+
throw new ProvisionEnvError(`unknown option ${arg}`);
|
|
117
|
+
else if (parsed.target === undefined)
|
|
118
|
+
parsed.target = arg;
|
|
119
|
+
else
|
|
120
|
+
throw new ProvisionEnvError("expected at most one target directory");
|
|
121
|
+
}
|
|
122
|
+
return parsed;
|
|
123
|
+
}
|
|
124
|
+
function readProjectId(dir) {
|
|
125
|
+
try {
|
|
126
|
+
const raw = JSON.parse(readFileSync(join(dir, ".vercel", "project.json"), "utf8"));
|
|
127
|
+
if (typeof raw.projectId === "string" && raw.projectId.length > 0)
|
|
128
|
+
return raw.projectId;
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
// fall through
|
|
132
|
+
}
|
|
133
|
+
throw new ProvisionEnvError(`${join(dir, ".vercel", "project.json")} has no readable projectId; run \`vercel link\` there, then re-run.`);
|
|
134
|
+
}
|
|
135
|
+
const envRm = (name) => ["env", "rm", name, "production", "--yes"];
|
|
136
|
+
const envAdd = (name) => ["env", "add", name, "production"];
|
|
137
|
+
/** The commands that set `vars` by hand, from `dir`. */
|
|
138
|
+
function manualCommands(dir, vars) {
|
|
139
|
+
return [
|
|
140
|
+
` cd '${dir}'`,
|
|
141
|
+
...Object.entries(vars).map(([name, value]) => ` printf '%s' '${value}' | vercel ${envAdd(name).join(" ")}`),
|
|
142
|
+
].join("\n");
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Run `provision-env` with `argv` = everything after the `provision-env` word.
|
|
146
|
+
* Returns the process exit code; never throws for an expected failure.
|
|
147
|
+
*/
|
|
148
|
+
export function runProvisionEnv(argv, io) {
|
|
149
|
+
const runner = io.runner ?? defaultRunner;
|
|
150
|
+
const fail = (message) => {
|
|
151
|
+
io.stderr(`[create-ailk] provision-env: ${message}\n`);
|
|
152
|
+
return 1;
|
|
153
|
+
};
|
|
154
|
+
let args;
|
|
155
|
+
let values;
|
|
156
|
+
try {
|
|
157
|
+
args = parseProvisionEnvArgs(argv);
|
|
158
|
+
if (args.help) {
|
|
159
|
+
io.stdout(PROVISION_ENV_HELP);
|
|
160
|
+
return 0;
|
|
161
|
+
}
|
|
162
|
+
if (args.webOrigin === undefined || args.mcpOrigin === undefined) {
|
|
163
|
+
throw new ProvisionEnvError(`--web-origin and --mcp-origin are both required.\n\n${PROVISION_ENV_HELP}`);
|
|
164
|
+
}
|
|
165
|
+
values = provisionEnv({ webOrigin: args.webOrigin, mcpOrigin: args.mcpOrigin });
|
|
166
|
+
}
|
|
167
|
+
catch (err) {
|
|
168
|
+
if (err instanceof ProvisionEnvError)
|
|
169
|
+
return fail(err.message);
|
|
170
|
+
throw err;
|
|
171
|
+
}
|
|
172
|
+
const target = resolve(io.cwd, args.target ?? ".");
|
|
173
|
+
if (!existsSync(join(target, "architecture.yaml"))) {
|
|
174
|
+
return fail(`not a scaffolded repo: no architecture.yaml at ${target}. Run it from your site's root, or pass the site's directory.`);
|
|
175
|
+
}
|
|
176
|
+
for (const app of APPS) {
|
|
177
|
+
const dir = join(target, app.dir);
|
|
178
|
+
const vars = values[app.key];
|
|
179
|
+
if (!existsSync(join(dir, ".vercel", "project.json"))) {
|
|
180
|
+
io.stdout(`[create-ailk] ${app.dir} has no Vercel link (.vercel/project.json); set these on its host yourself:\n${Object.entries(vars)
|
|
181
|
+
.map(([name, value]) => ` ${name}=${value}`)
|
|
182
|
+
.join("\n")}\n`);
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
let projectId;
|
|
186
|
+
try {
|
|
187
|
+
projectId = readProjectId(dir);
|
|
188
|
+
}
|
|
189
|
+
catch (err) {
|
|
190
|
+
return fail(err.message);
|
|
191
|
+
}
|
|
192
|
+
io.stdout(`[create-ailk] Setting ${Object.keys(vars).join(", ")} (production) on Vercel project ${projectId}, linked at ${dir}\n`);
|
|
193
|
+
for (const [name, value] of Object.entries(vars)) {
|
|
194
|
+
const rm = runner(envRm(name), { cwd: dir });
|
|
195
|
+
if (rm.error?.code === "ENOENT") {
|
|
196
|
+
return fail(`the vercel CLI was not found; nothing was pushed. Install it (npm i -g vercel), or set them by hand:\n${manualCommands(dir, vars)}`);
|
|
197
|
+
}
|
|
198
|
+
const removed = rm.error === undefined && rm.status === 0;
|
|
199
|
+
const add = runner(envAdd(name), { cwd: dir, input: value });
|
|
200
|
+
if (add.error !== undefined || add.status !== 0) {
|
|
201
|
+
const state = removed
|
|
202
|
+
? `The production variable ${name} is now UNSET there (the old value was removed).`
|
|
203
|
+
: `The production variable ${name} was not changed.`;
|
|
204
|
+
return fail(`\`vercel ${envAdd(name).join(" ")}\` failed on project ${projectId}. ${state} Finish with:\n${manualCommands(dir, { [name]: value })}`);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
io.stdout("[create-ailk] MCP discovery and OAuth variables set; the next deploy reads them.\n");
|
|
209
|
+
return 0;
|
|
210
|
+
}
|
package/dist/surfaces.js
CHANGED
|
@@ -818,6 +818,8 @@ export const ROUTE_SINCE = {
|
|
|
818
818
|
"(auth)/forgot-password": "0.24.0",
|
|
819
819
|
"(auth)/reset-password": "0.24.0",
|
|
820
820
|
"(auth)/verify-email": "0.24.0",
|
|
821
|
+
// #10357 D13 — the MCP consent step.
|
|
822
|
+
"(auth)/consent": "0.53.0",
|
|
821
823
|
// #5469 D2 — the generic `[slug]` content route. A genuinely new route
|
|
822
824
|
// directory, so it attaches at today's fixed-group version, the same rule
|
|
823
825
|
// #5479, #5371 and #5048 applied.
|
|
@@ -1210,6 +1212,7 @@ export const ROUTE_MESSAGE_NAMESPACES = {
|
|
|
1210
1212
|
"(auth)/forgot-password": ["auth"],
|
|
1211
1213
|
"(auth)/reset-password": ["auth"],
|
|
1212
1214
|
"(auth)/verify-email": ["auth"],
|
|
1215
|
+
"(auth)/consent": ["mcpConsent"],
|
|
1213
1216
|
"(authed)/account": ["account"],
|
|
1214
1217
|
"(authed)/account/inbox/[kind]/[id]": ["account"],
|
|
1215
1218
|
"(authed)/account/settings": ["account"],
|
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,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-ailk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.30.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.53.0",
|
|
32
|
+
"@working-theory/templates": "0.53.0"
|
|
33
33
|
},
|
|
34
34
|
"repository": {
|
|
35
35
|
"type": "git",
|
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
|
|
@@ -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=
|