@avocadostudio-ai/orchestrator-core 0.1.0 → 0.2.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.
Files changed (61) hide show
  1. package/dist/agent/sites-agent-context.js +3 -2
  2. package/dist/agent/sites-agent-shared.js +1 -0
  3. package/dist/chat/anthropic-planner.js +3 -3
  4. package/dist/chat/chat-pipeline.js +122 -20
  5. package/dist/chat/gemini-planner.js +3 -3
  6. package/dist/chat/planner.js +7 -5
  7. package/dist/chat/prompts.js +6 -1
  8. package/dist/cms/adapter.d.ts +159 -1
  9. package/dist/cms/adapter.js +19 -1
  10. package/dist/cms/bootstrap.d.ts +46 -1
  11. package/dist/cms/bootstrap.js +126 -2
  12. package/dist/cms/index.d.ts +3 -2
  13. package/dist/cms/index.js +2 -1
  14. package/dist/errors.d.ts +9 -1
  15. package/dist/handler/auth.d.ts +79 -0
  16. package/dist/handler/auth.js +113 -0
  17. package/dist/handler/create-orchestrator.d.ts +205 -0
  18. package/dist/handler/create-orchestrator.js +1599 -0
  19. package/dist/http/access-tokens.d.ts +58 -0
  20. package/dist/http/access-tokens.js +161 -0
  21. package/dist/http/audio-actions.d.ts +121 -0
  22. package/dist/http/audio-actions.js +248 -0
  23. package/dist/http/blocks-actions.d.ts +31 -0
  24. package/dist/http/blocks-actions.js +31 -0
  25. package/dist/http/draft-provenance.d.ts +68 -0
  26. package/dist/http/draft-provenance.js +101 -0
  27. package/dist/http/history-actions.d.ts +58 -0
  28. package/dist/http/history-actions.js +169 -0
  29. package/dist/http/image-generate-actions.d.ts +268 -0
  30. package/dist/http/image-generate-actions.js +546 -0
  31. package/dist/http/ops-actions.d.ts +51 -0
  32. package/dist/http/ops-actions.js +79 -0
  33. package/dist/http/publish-actions.d.ts +153 -0
  34. package/dist/http/publish-actions.js +323 -0
  35. package/dist/http/restore-actions.d.ts +67 -0
  36. package/dist/http/restore-actions.js +145 -0
  37. package/dist/http/screenshot-actions.d.ts +108 -0
  38. package/dist/http/screenshot-actions.js +181 -0
  39. package/dist/http/session-actions.d.ts +35 -0
  40. package/dist/http/session-actions.js +98 -0
  41. package/dist/http/telemetry-feedback-actions.d.ts +53 -0
  42. package/dist/http/telemetry-feedback-actions.js +68 -0
  43. package/dist/http/unsplash-actions.d.ts +64 -0
  44. package/dist/http/unsplash-actions.js +81 -0
  45. package/dist/http/variations-actions.d.ts +102 -0
  46. package/dist/http/variations-actions.js +104 -0
  47. package/dist/index.d.ts +4 -1
  48. package/dist/index.js +21 -1
  49. package/dist/nlp/deterministic-planner-refs.d.ts +1 -1
  50. package/dist/nlp/deterministic-planner-suggestions.d.ts +10 -0
  51. package/dist/nlp/deterministic-planner-suggestions.js +37 -11
  52. package/dist/nlp/plan-normalizer.js +18 -2
  53. package/dist/ops/ops-engine.js +219 -14
  54. package/dist/state/session-state.d.ts +56 -1
  55. package/dist/state/session-state.js +92 -6
  56. package/dist/state/sqlite-store-singleton.d.ts +22 -0
  57. package/dist/state/sqlite-store-singleton.js +49 -1
  58. package/dist/state/sqlite-store.d.ts +5 -0
  59. package/dist/state/sqlite-store.js +125 -2
  60. package/dist/telemetry/chat-telemetry.js +6 -1
  61. package/package.json +12 -16
@@ -1,10 +1,39 @@
1
- import { getSessionDraft, ensureHeroImageProps, bumpVersion, schedulePersistState } from "../state/session-state.js";
1
+ import { resolveCapabilities } from "./adapter.js";
2
+ import { setSessionCapabilities, getSessionDraft, ensureHeroImageProps, bumpVersion, schedulePersistState } from "../state/session-state.js";
2
3
  const DEFAULT_MAX_ATTEMPTED = 5000;
4
+ // One site, a handful of sessions, is the shape a library-mode process has. The
5
+ // cap is only here so a long-lived multi-site host cannot accumulate page sets
6
+ // without bound.
7
+ const DEFAULT_MAX_BASELINES = 50;
8
+ // A warmed page list is a cold-start bridge, not a cache. It only has to cover
9
+ // the gap between "the runtime mounted" and "the first tool call lands", which
10
+ // is seconds for an MCP host attaching to a running process. One minute is
11
+ // generous enough that even a pathologically slow adapter finishes inside the
12
+ // window and the first agent call still gets the benefit, and short enough that
13
+ // a session created later never gets seeded from a page list the CMS has since
14
+ // moved past — a warm-once-at-boot process serving an hour-old page list to a
15
+ // fresh session would hand out content someone has already edited upstream.
16
+ const DEFAULT_WARM_TTL_MS = 60_000;
3
17
  export function createCmsBootstrapCache(opts = {}) {
4
18
  const inFlight = new Map();
5
19
  // Set is insertion-ordered, so FIFO eviction is just "delete the first key".
6
20
  const attempted = new Set();
7
21
  const maxAttempted = Math.max(1, opts.maxAttempted ?? DEFAULT_MAX_ATTEMPTED);
22
+ const warmTtlMs = Math.max(0, opts.warmTtlMs ?? DEFAULT_WARM_TTL_MS);
23
+ const capabilityOverride = opts.capabilities;
24
+ // Map is insertion-ordered, so FIFO eviction is "delete the first key".
25
+ const baselines = new Map();
26
+ const maxBaselines = Math.max(1, opts.maxBaselines ?? DEFAULT_MAX_BASELINES);
27
+ let warmEntry = null;
28
+ function rememberBaseline(session, pages) {
29
+ baselines.set(session, pages);
30
+ while (baselines.size > maxBaselines) {
31
+ const oldest = baselines.keys().next().value;
32
+ if (oldest === undefined)
33
+ break;
34
+ baselines.delete(oldest);
35
+ }
36
+ }
8
37
  function markAttempted(session) {
9
38
  attempted.add(session);
10
39
  while (attempted.size > maxAttempted) {
@@ -14,9 +43,85 @@ export function createCmsBootstrapCache(opts = {}) {
14
43
  attempted.delete(oldest);
15
44
  }
16
45
  }
46
+ // Staleness is measured from the moment the fetch *settled*, not from when it
47
+ // started: a fetch still in flight is about to produce a fresh read, however
48
+ // long it has been running, so it is always worth awaiting.
49
+ function isExpired(entry) {
50
+ return entry.settledAt !== null && Date.now() - entry.settledAt > warmTtlMs;
51
+ }
52
+ function warm(adapter, log) {
53
+ if (!adapter)
54
+ return;
55
+ // Adapter identity, not `adapter.id`: two runtimes in one process can share
56
+ // an adapter id while pointing at different sites, and serving one site's
57
+ // pages to the other's sessions is the one failure this must never cause.
58
+ if (warmEntry && warmEntry.adapter === adapter && !isExpired(warmEntry))
59
+ return;
60
+ const entry = {
61
+ adapter,
62
+ settledAt: null,
63
+ // Replaced on the next statement. The placeholder exists only because the
64
+ // fetch's own callbacks have to reach back into `entry` to stamp it.
65
+ promise: Promise.resolve(null)
66
+ };
67
+ entry.promise = Promise.resolve()
68
+ /*
69
+ * `draft`, because this list seeds a session and a session is a working
70
+ * copy: the pages someone is about to edit should include the edits they
71
+ * have already made in the CMS and not published. An adapter that does
72
+ * not distinguish ignores the argument and answers exactly as before.
73
+ *
74
+ * Note what this does *not* change: the baseline `ensure` keeps for the
75
+ * publish diff is still this same list, because that baseline's job is
76
+ * "what the adapter last handed us", so the round-trip diff compares
77
+ * like with like. Only `/publish/diff`, which asks what is live, reads
78
+ * the other side.
79
+ */
80
+ .then(() => adapter.getPages({ perspective: "draft" }))
81
+ .then((pages) => {
82
+ entry.settledAt = Date.now();
83
+ log.info({ adapter: adapter.id, count: pages.length }, "cms-bootstrap: warmed adapter page list");
84
+ return pages;
85
+ })
86
+ .catch((err) => {
87
+ // Forget the failure entirely so the first session's ensure() gets a
88
+ // clean retry instead of inheriting a dead prefetch.
89
+ if (warmEntry === entry)
90
+ warmEntry = null;
91
+ log.warn({
92
+ adapter: adapter.id,
93
+ err: err instanceof Error ? err.stack ?? err.message : String(err)
94
+ }, "cms-bootstrap: warm fetch failed; first session will retry");
95
+ return null;
96
+ });
97
+ warmEntry = entry;
98
+ }
99
+ /** The warmed page list if one applies to this adapter and is still fresh. */
100
+ function warmedPages(adapter) {
101
+ const entry = warmEntry;
102
+ if (!entry || entry.adapter !== adapter)
103
+ return null;
104
+ if (isExpired(entry)) {
105
+ if (warmEntry === entry)
106
+ warmEntry = null;
107
+ return null;
108
+ }
109
+ return entry.promise;
110
+ }
17
111
  async function ensure(session, adapter, log) {
18
112
  if (!adapter)
19
113
  return;
114
+ /*
115
+ * Record what this adapter can honour before anything else, on every call
116
+ * rather than once: this is the one place that knows both a session key
117
+ * and the adapter behind it, and the ops engine reads it by session key to
118
+ * refuse an operation the site cannot publish. Behind the `attempted`
119
+ * early-return it would be written for the first request of a session and
120
+ * lost to a process restart that reloaded the draft from SQLite — the
121
+ * draft survives, so `ensure` returns early, so the capabilities never
122
+ * land, so the engine silently reverts to permitting everything.
123
+ */
124
+ setSessionCapabilities(session, resolveCapabilities(adapter, capabilityOverride));
20
125
  if (attempted.has(session))
21
126
  return;
22
127
  const existing = inFlight.get(session);
@@ -27,11 +132,20 @@ export function createCmsBootstrapCache(opts = {}) {
27
132
  if (draft.size > 0)
28
133
  return; // finally-block records the attempt
29
134
  try {
30
- const pages = await adapter.getPages();
135
+ const warmed = warmedPages(adapter);
136
+ const pages = (warmed ? await warmed : null) ?? (await adapter.getPages({ perspective: "draft" }));
31
137
  if (pages.length === 0) {
32
138
  log.info({ session, adapter: adapter.id }, "cms-bootstrap: adapter returned no pages");
33
139
  return;
34
140
  }
141
+ /*
142
+ * Keep the pre-edit copy. The pages are already being cloned into the
143
+ * draft, so a baseline for the eventual publish diff costs one more
144
+ * clone and — the point — no second adapter read. Re-reading upstream
145
+ * at publish time is 45 sequential CMS calls on the integration that
146
+ * motivated this.
147
+ */
148
+ rememberBaseline(session, pages.map((page) => structuredClone(page)));
35
149
  for (const page of pages) {
36
150
  const copy = structuredClone(page);
37
151
  ensureHeroImageProps(copy);
@@ -62,9 +176,15 @@ export function createCmsBootstrapCache(opts = {}) {
62
176
  }
63
177
  return {
64
178
  ensure,
179
+ warm,
180
+ baselineFor(session) {
181
+ return baselines.get(session) ?? null;
182
+ },
65
183
  reset() {
66
184
  inFlight.clear();
67
185
  attempted.clear();
186
+ baselines.clear();
187
+ warmEntry = null;
68
188
  },
69
189
  get attemptedSize() {
70
190
  return attempted.size;
@@ -79,6 +199,10 @@ const defaultCache = createCmsBootstrapCache();
79
199
  export async function ensureSessionBootstrapped(session, adapter, log) {
80
200
  return defaultCache.ensure(session, adapter, log);
81
201
  }
202
+ /** Warm the process-default cache. Runtime-scoped caches own their own `warm()`. */
203
+ export function warmSessionBootstrap(adapter, log) {
204
+ defaultCache.warm(adapter, log);
205
+ }
82
206
  /** Test helper — clears the default process cache. Runtime-scoped caches own their own `reset()`. */
83
207
  export function _resetCmsBootstrapCache() {
84
208
  defaultCache.reset();
@@ -1,4 +1,5 @@
1
- export type { CmsAdapter, CmsInlineAsset, CmsPublishContext, CmsPublishResult } from "./adapter.ts";
1
+ export type { CmsAdapter, CmsCapabilities, CmsInlineAsset, CmsPublishContext, CmsPublishResult, CmsPerspective, CmsReadOptions, ResolvedCapabilities } from "./adapter.ts";
2
+ export { resolveCapabilities } from "./adapter.ts";
2
3
  export { jsonFileAdapter, type JsonFileAdapterOptions } from "./json-file-adapter.ts";
3
4
  export { editorApiAdapter, type EditorApiAdapterOptions } from "./editor-api-adapter.ts";
4
- export { ensureSessionBootstrapped, createCmsBootstrapCache, _resetCmsBootstrapCache, type CmsBootstrapCache, type CmsBootstrapCacheOptions } from "./bootstrap.ts";
5
+ export { ensureSessionBootstrapped, createCmsBootstrapCache, _resetCmsBootstrapCache, type CmsBootstrapCache, type CmsBootstrapCacheOptions, warmSessionBootstrap } from "./bootstrap.ts";
package/dist/cms/index.js CHANGED
@@ -1,3 +1,4 @@
1
+ export { resolveCapabilities } from "./adapter.js";
1
2
  export { jsonFileAdapter } from "./json-file-adapter.js";
2
3
  export { editorApiAdapter } from "./editor-api-adapter.js";
3
- export { ensureSessionBootstrapped, createCmsBootstrapCache, _resetCmsBootstrapCache } from "./bootstrap.js";
4
+ export { ensureSessionBootstrapped, createCmsBootstrapCache, _resetCmsBootstrapCache, warmSessionBootstrap } from "./bootstrap.js";
package/dist/errors.d.ts CHANGED
@@ -3,7 +3,15 @@
3
3
  * operational validation. Superset of the former `GuardrailErrorCategory`
4
4
  * and `PlannerFailureReasonCategory` types.
5
5
  */
6
- export type ErrorCategory = "schema_violation" | "ambiguity" | "not_found" | "no_effective_change" | "planner_refusal" | "incomplete_output" | "malformed_output" | "internal_error" | "canceled" | "operation_failed";
6
+ export type ErrorCategory = "schema_violation" | "ambiguity" | "not_found" | "no_effective_change" | "planner_refusal" | "incomplete_output" | "malformed_output" | "internal_error" | "canceled" | "operation_failed"
7
+ /**
8
+ * The operation is well-formed and would apply, but the site behind this
9
+ * session has declared it cannot honour it — see `CmsCapabilities`. Distinct
10
+ * from `schema_violation` (the request is wrong) and from
11
+ * `operation_failed` (it was attempted and did not work): nothing here is
12
+ * wrong and nothing was attempted.
13
+ */
14
+ | "unsupported_by_site";
7
15
  export type GuardrailErrorCategory = ErrorCategory;
8
16
  export type PlannerFailureReasonCategory = Extract<ErrorCategory, "schema_violation" | "planner_refusal" | "incomplete_output" | "malformed_output" | "internal_error">;
9
17
  /**
@@ -0,0 +1,79 @@
1
+ /**
2
+ * The credential gate for library mode.
3
+ *
4
+ * `createOrchestrator` mounts on the customer's own production domain and can
5
+ * edit and publish their content. Until this module existed it did so with no
6
+ * credential check of any kind: `grep -n 'auth' orchestrator.ts` returned
7
+ * nothing, and every route was reachable by anyone who guessed the path.
8
+ *
9
+ * The shape is deliberately the agent surface's, which faced the same question
10
+ * a year earlier and answered it by refusing to mount rather than by hoping:
11
+ *
12
+ * - a host that supplies `config.auth` owns the decision entirely;
13
+ * - a host that configures `ACCESS_PASSWORD_HASH` or `ORCHESTRATOR_ACCESS_TOKEN`
14
+ * gets the same token gate the standalone server enforces, minted and
15
+ * validated by the same module, so one editor build talks to both;
16
+ * - a host that configured neither is open in development and **closed in
17
+ * production**. Not warned about — closed. A default that is convenient in
18
+ * dev and catastrophic in prod has to pick prod.
19
+ *
20
+ * The escape hatch from that last rule is one line, `auth: () => true`, which
21
+ * is a thing somebody types on purpose and cannot arrive at by omission.
22
+ */
23
+ import type { Logger } from "../logger.ts";
24
+ /**
25
+ * Whatever the host's `auth` hook wants to hand downstream — a user id, a role,
26
+ * a tenant. Nothing in the orchestrator reads it yet; it is threaded through so
27
+ * that when a route needs "who is this", the hook does not have to be rewritten.
28
+ */
29
+ export type AuthContext = Record<string, unknown>;
30
+ /**
31
+ * Decide whether a request may proceed.
32
+ *
33
+ * Return `false` (or `null`/`undefined`) to refuse, `true` to allow, or an
34
+ * object to allow and attach context. Throwing is treated as a refusal, so a
35
+ * hook that awaits a session lookup does not have to catch its own errors.
36
+ */
37
+ export type OrchestratorAuth = (request: Request) => boolean | AuthContext | null | undefined | Promise<boolean | AuthContext | null | undefined>;
38
+ export type AuthMode =
39
+ /** `config.auth` supplied — the host decides. */
40
+ "hook"
41
+ /** `ACCESS_PASSWORD_HASH` and/or `ORCHESTRATOR_ACCESS_TOKEN` configured. */
42
+ | "token"
43
+ /** Nothing configured, not production. Open, and says so at mount. */
44
+ | "open-dev"
45
+ /** Nothing configured, production. Everything is refused. */
46
+ | "closed";
47
+ export interface ResolvedAuth {
48
+ mode: AuthMode;
49
+ /** One line, for the mount-time log. Never sent to a caller. */
50
+ reason: string;
51
+ }
52
+ export declare function isPublicPath(path: string, method: string): boolean;
53
+ /** True when either credential is configured, i.e. the built-in gate can run. */
54
+ export declare function hasConfiguredCredential(): boolean;
55
+ /**
56
+ * Resolved per request rather than once at mount. `NODE_ENV` and the credential
57
+ * env vars are read at call time everywhere else in this codebase for the same
58
+ * reason: route modules are imported before `dotenv.config()` runs, and a value
59
+ * captured at module load is a value from before the host configured anything.
60
+ */
61
+ export declare function resolveAuth(auth: OrchestratorAuth | undefined): ResolvedAuth;
62
+ /** The one body shape the editor's fetch shim recognises as "re-prompt". */
63
+ export declare function unauthorizedResponse(cors: Record<string, string>): Response;
64
+ export interface AuthCheckResult {
65
+ /** Non-null means refuse and return this. */
66
+ response: Response | null;
67
+ /** Whatever the hook attached, for a route that wants to know who called. */
68
+ context: AuthContext | null;
69
+ }
70
+ /**
71
+ * Run the gate for one request. `path` is already base-path-stripped.
72
+ */
73
+ export declare function checkAuth(args: {
74
+ request: Request;
75
+ path: string;
76
+ auth: OrchestratorAuth | undefined;
77
+ cors: Record<string, string>;
78
+ log: Logger;
79
+ }): Promise<AuthCheckResult>;
@@ -0,0 +1,113 @@
1
+ /**
2
+ * The credential gate for library mode.
3
+ *
4
+ * `createOrchestrator` mounts on the customer's own production domain and can
5
+ * edit and publish their content. Until this module existed it did so with no
6
+ * credential check of any kind: `grep -n 'auth' orchestrator.ts` returned
7
+ * nothing, and every route was reachable by anyone who guessed the path.
8
+ *
9
+ * The shape is deliberately the agent surface's, which faced the same question
10
+ * a year earlier and answered it by refusing to mount rather than by hoping:
11
+ *
12
+ * - a host that supplies `config.auth` owns the decision entirely;
13
+ * - a host that configures `ACCESS_PASSWORD_HASH` or `ORCHESTRATOR_ACCESS_TOKEN`
14
+ * gets the same token gate the standalone server enforces, minted and
15
+ * validated by the same module, so one editor build talks to both;
16
+ * - a host that configured neither is open in development and **closed in
17
+ * production**. Not warned about — closed. A default that is convenient in
18
+ * dev and catastrophic in prod has to pick prod.
19
+ *
20
+ * The escape hatch from that last rule is one line, `auth: () => true`, which
21
+ * is a thing somebody types on purpose and cannot arrive at by omission.
22
+ */
23
+ import { isAccessGateEnabled, isValidAccessToken, extractAccessToken } from "../http/access-tokens.js";
24
+ /**
25
+ * Paths that answer before a caller could possibly hold a credential.
26
+ *
27
+ * `/auth/*` is the exchange that produces one. `GET /generated-images/*` is
28
+ * fetched by `<img src>` on the rendered page, which cannot attach a header —
29
+ * gating it would blank every uploaded image in the live site, not just in the
30
+ * editor. Both are read-only and neither reveals session content.
31
+ */
32
+ const PUBLIC_PATHS = new Set(["/auth/status", "/auth/verify"]);
33
+ export function isPublicPath(path, method) {
34
+ if (method === "OPTIONS")
35
+ return true;
36
+ if (PUBLIC_PATHS.has(path))
37
+ return true;
38
+ if (method === "GET" && path.startsWith("/generated-images/"))
39
+ return true;
40
+ return false;
41
+ }
42
+ function isProduction() {
43
+ return (process.env.NODE_ENV ?? "").trim() === "production";
44
+ }
45
+ /** True when either credential is configured, i.e. the built-in gate can run. */
46
+ export function hasConfiguredCredential() {
47
+ return isAccessGateEnabled() || Boolean(process.env.ORCHESTRATOR_ACCESS_TOKEN?.trim());
48
+ }
49
+ /**
50
+ * Resolved per request rather than once at mount. `NODE_ENV` and the credential
51
+ * env vars are read at call time everywhere else in this codebase for the same
52
+ * reason: route modules are imported before `dotenv.config()` runs, and a value
53
+ * captured at module load is a value from before the host configured anything.
54
+ */
55
+ export function resolveAuth(auth) {
56
+ if (auth)
57
+ return { mode: "hook", reason: "config.auth supplied — the host decides" };
58
+ if (hasConfiguredCredential()) {
59
+ return {
60
+ mode: "token",
61
+ reason: `built-in token gate (${isAccessGateEnabled() ? "password" : "static token"})`
62
+ };
63
+ }
64
+ if (isProduction()) {
65
+ return {
66
+ mode: "closed",
67
+ reason: "NODE_ENV=production with no credential — every request is refused. " +
68
+ "Set ACCESS_PASSWORD_HASH or ORCHESTRATOR_ACCESS_TOKEN, or pass config.auth."
69
+ };
70
+ }
71
+ return { mode: "open-dev", reason: "no credential configured and NODE_ENV is not production — open" };
72
+ }
73
+ /** The one body shape the editor's fetch shim recognises as "re-prompt". */
74
+ export function unauthorizedResponse(cors) {
75
+ return new Response(JSON.stringify({ error: "unauthorized" }), {
76
+ status: 401,
77
+ headers: { "content-type": "application/json", ...cors }
78
+ });
79
+ }
80
+ /**
81
+ * Run the gate for one request. `path` is already base-path-stripped.
82
+ */
83
+ export async function checkAuth(args) {
84
+ const { request, path, auth, cors, log } = args;
85
+ if (isPublicPath(path, request.method))
86
+ return { response: null, context: null };
87
+ const resolved = resolveAuth(auth);
88
+ if (resolved.mode === "open-dev")
89
+ return { response: null, context: null };
90
+ if (resolved.mode === "closed") {
91
+ log.error({ path }, `[auth] refused — ${resolved.reason}`);
92
+ return { response: unauthorizedResponse(cors), context: null };
93
+ }
94
+ if (resolved.mode === "token") {
95
+ if (isValidAccessToken(extractAccessToken(request)))
96
+ return { response: null, context: null };
97
+ return { response: unauthorizedResponse(cors), context: null };
98
+ }
99
+ // mode === "hook"
100
+ let verdict;
101
+ try {
102
+ verdict = await auth(request);
103
+ }
104
+ catch (err) {
105
+ // A hook that threw has not said yes. Refuse, and say why in the log rather
106
+ // than in the response — the caller does not get to learn how it failed.
107
+ log.warn({ err: err instanceof Error ? err.message : String(err), path }, "[auth] hook threw — refusing");
108
+ return { response: unauthorizedResponse(cors), context: null };
109
+ }
110
+ if (!verdict)
111
+ return { response: unauthorizedResponse(cors), context: null };
112
+ return { response: null, context: verdict === true ? null : verdict };
113
+ }
@@ -0,0 +1,205 @@
1
+ import { type AIProvider, type ModelKey } from "../state/session-state.ts";
2
+ import { type ScreenshotCapture } from "../http/screenshot-actions.ts";
3
+ import { type Logger } from "../logger.ts";
4
+ import type { CmsAdapter, CmsCapabilities } from "../cms/adapter.ts";
5
+ import { type OrchestratorAuth, type AuthContext } from "./auth.ts";
6
+ export type { OrchestratorAuth, AuthContext };
7
+ export interface CreateOrchestratorConfig {
8
+ /**
9
+ * Provider model overrides. Defaults to env vars (OPENAI_MODEL_*, ANTHROPIC_MODEL_*,
10
+ * GOOGLE_GENAI_MODEL_*). Pass an explicit object to override per-tier model names.
11
+ */
12
+ modelLookup?: Record<AIProvider, Record<ModelKey, string>>;
13
+ /**
14
+ * Override available providers. Defaults to whichever API keys are present in
15
+ * process.env (OPENAI_API_KEY / ANTHROPIC_API_KEY / GOOGLE_GENAI_API_KEY).
16
+ */
17
+ availableProviders?: AIProvider[];
18
+ /** Pino-shaped logger. Defaults to a console-backed implementation. */
19
+ logger?: Logger;
20
+ /**
21
+ * Allowed CORS origins.
22
+ *
23
+ * Unset, this reflected whatever `Origin` arrived — every site on the web
24
+ * was allowed to read the response. It now reflects only outside production;
25
+ * under `NODE_ENV=production` an unset value sends no CORS headers at all,
26
+ * which permits same-origin callers and nobody else. Pass an explicit
27
+ * allow-list to open it back up, `"*"` to reflect deliberately, or `null` to
28
+ * turn the SDK's CORS handling off entirely and let Next middleware own it.
29
+ */
30
+ corsOrigins?: string[] | "*" | null;
31
+ /**
32
+ * Decide whether a request may proceed.
33
+ *
34
+ * A library-mode mount can edit and publish the site it is embedded in, so it
35
+ * needs a credential. There are three ways to give it one, in precedence
36
+ * order:
37
+ *
38
+ * 1. **This hook.** Return `true`, or an object to attach context; return
39
+ * `false` to refuse. Use it to reuse the session the rest of your app
40
+ * already has — read a cookie, check a header, call your own auth.
41
+ * 2. **`ACCESS_PASSWORD_HASH` / `ORCHESTRATOR_ACCESS_TOKEN`.** With either
42
+ * set and no hook, the built-in gate runs: `/auth/verify` exchanges the
43
+ * password for a token, and every other route requires it as
44
+ * `x-access-token`, `Authorization: Bearer`, or `?accessToken=`. This is
45
+ * the same gate the standalone orchestrator enforces, minted by the same
46
+ * module — one editor build talks to both.
47
+ * 3. **Neither.** Open in development. **Closed in production** — every
48
+ * request is refused, because an unauthenticated publish endpoint on a
49
+ * customer's own domain is not a default anybody should be able to reach
50
+ * by forgetting something.
51
+ *
52
+ * To run open in production on purpose, say so: `auth: () => true`.
53
+ */
54
+ auth?: OrchestratorAuth;
55
+ /**
56
+ * Register the default builtin tools (unsplash-search, image-generate,
57
+ * gdrive-browse). Defaults to `false` — opt in only if you want those
58
+ * features, since registering them pulls in sharp + googleapis + image-gen
59
+ * SDKs at module load. Pass `true` for env-gated defaults, or an explicit
60
+ * subset like `["unsplash-search"]`.
61
+ */
62
+ builtinTools?: boolean | Array<"unsplash-search" | "image-generate" | "gdrive-browse">;
63
+ /**
64
+ * URL path prefix the handler is mounted under. Everything matching this
65
+ * prefix is stripped off `url.pathname` before route matching, so the rest
66
+ * of the handler can reason in terms of `/chat`, `/chat/stream`, etc.
67
+ *
68
+ * Defaults to `/api/avocado` (the catch-all path used in the Next.js
69
+ * example app). Set to `""` if you want to match against the full pathname
70
+ * yourself, or override to e.g. `/api/orchestrator` if you mount the
71
+ * catch-all there.
72
+ */
73
+ basePath?: string;
74
+ /**
75
+ * Source-of-truth adapter for the site's existing content. Called once per
76
+ * session on the first chat request to seed SQLite with the site's pages.
77
+ * Without an adapter the orchestrator starts with an empty draft, which
78
+ * means the planner has no pages to edit — every "edit the homepage" turn
79
+ * returns `page not found`.
80
+ *
81
+ * Provided implementations:
82
+ * - `jsonFileAdapter({ path })` — read PageDoc[] from a JSON file
83
+ * - `editorApiAdapter({ origin })` — fetch from `${origin}/api/editor/pages`
84
+ *
85
+ * Import from `@avocadostudio-ai/orchestrator-core/cms`.
86
+ */
87
+ adapter?: CmsAdapter;
88
+ /**
89
+ * Site identifier used to scope session state in SQLite. When `adapter` is
90
+ * set, defaults to `"library"` so the demo-content seed path is bypassed
91
+ * (an unscoped session falls back to the orchestrator's bundled demo pages).
92
+ * Override if you want to run multiple distinct sites against one process.
93
+ *
94
+ * Precedence on incoming requests:
95
+ * - adapter configured: `siteId` (or its `"library"` default) ALWAYS wins
96
+ * over `body.siteId`. The library-mode orchestrator owns its identity.
97
+ * - no adapter: explicit `siteId` wins; otherwise `body.siteId` is used.
98
+ */
99
+ siteId?: string;
100
+ /**
101
+ * Register the site's block schemas before the orchestrator's first chat
102
+ * request. Use this instead of relying on side-effect import order: in
103
+ * library mode, the orchestrator's transitive imports of
104
+ * `@avocadostudio-ai/shared` re-register canonical Hero/CTA/etc. on the
105
+ * shared globalThis registry, often AFTER the host app's overrides.
106
+ *
107
+ * **Fires once per orchestrator runtime** — at `buildRuntime` time, not
108
+ * per-request. For long-lived production processes this is fine, but if
109
+ * the host re-builds the runtime on hot-reload, ensure the schemas survive
110
+ * (registerBlock is idempotent — calling again is safe). Pair this with
111
+ * the same `registerBlocks` passed to `createEditorApiHandler`, which
112
+ * re-runs on every `/blocks` request, to cover request-time too.
113
+ */
114
+ registerBlocks?: () => void;
115
+ /**
116
+ * Local directory where `POST /image/upload` writes uploaded files and
117
+ * `GET /generated-images/:fileName` reads them back. Defaults to
118
+ * `.data/generated-images` under the host process CWD.
119
+ *
120
+ * **POC-grade storage.** Files live on the orchestrator host's local disk
121
+ * with no CDN, no image transforms, and no durability guarantee — on an
122
+ * ephemeral filesystem (e.g. a container without a mounted volume) uploads
123
+ * vanish on redeploy. See `docs/image-storage-options.md` for the path to a
124
+ * blob/CDN backend; that swap is contained to these two routes.
125
+ */
126
+ imageDir?: string;
127
+ /**
128
+ * Capture backend for `POST /preview/screenshot` — an agent's only way to
129
+ * see what it just edited.
130
+ *
131
+ * Left unset, the route resolves `@avocadostudio-ai/migration-sdk` at call
132
+ * time. That package carries Playwright and a browser binary, so it is
133
+ * deliberately not a dependency of your site: install it yourself if you
134
+ * want screenshots, or pass a function here to use a capture service you
135
+ * already run. Without either, the route answers 503 and says so — it never
136
+ * pretends to have photographed anything.
137
+ */
138
+ screenshot?: ScreenshotCapture;
139
+ /**
140
+ * Where this site is reachable, e.g. `http://localhost:3000`. Used by
141
+ * `POST /preview/screenshot` as the base of the URL it photographs.
142
+ *
143
+ * Without it the route can only work after somebody calls
144
+ * `POST /sites/register` — a call an embedded site has no reason to make
145
+ * about itself, and which the MCP `avocado-register-site` tool could not make
146
+ * either, because library mode did not serve that route.
147
+ */
148
+ previewUrl?: string;
149
+ /**
150
+ * The route this site renders orchestrator drafts on. Either a prefix the
151
+ * page slug is appended to (`/avocado`) or a template naming where the slug
152
+ * goes (`/preview/{slug}/draft`). Defaults to `/preview-draft`, which is what
153
+ * `create-ai-site-editor` scaffolds.
154
+ *
155
+ * A site that wired Avocado into its own app almost certainly has its own
156
+ * route here — declare it, or every draft screenshot photographs a 404.
157
+ */
158
+ draftPath?: string;
159
+ /**
160
+ * The block types this site actually renders.
161
+ *
162
+ * Importing anything from `@avocadostudio-ai/shared` registers Avocado's 18
163
+ * built-ins transitively, so a site that brought its own blocks advertises
164
+ * both sets — and an agent reading `/blocks/manifest` could add a `Hero` that
165
+ * applies cleanly and renders nothing. Declare your own list and the manifest,
166
+ * the planner and `add_block` all narrow to it.
167
+ *
168
+ * Omit it if you render Avocado's blocks (a scaffolded site does). The
169
+ * declaration is exclusive: nothing outside the list is offered or accepted.
170
+ */
171
+ blockTypes?: readonly string[];
172
+ /**
173
+ * What this site can honour, for sites using a stock adapter that has no
174
+ * declaration of its own. Merged over `adapter.capabilities`, so a host can
175
+ * override one field without writing an adapter class.
176
+ *
177
+ * Every field is optional and unset means permitted — see `CmsCapabilities`.
178
+ * Declaring `createPages: false` makes the ops engine refuse `create_page`
179
+ * and `duplicate_page` on every write path, and makes the MCP server stop
180
+ * offering the matching tools.
181
+ */
182
+ capabilities?: CmsCapabilities;
183
+ }
184
+ /**
185
+ * A handler returned by {@link createOrchestrator}. Callable like the bare
186
+ * Web `(Request) => Promise<Response>` it always was, with an extra
187
+ * `dispose()` method to release the resumable-stream sweep timer (call this
188
+ * during dev hot-reload or test teardown).
189
+ */
190
+ export type OrchestratorHandler = ((request: Request) => Promise<Response>) & {
191
+ dispose(): Promise<void>;
192
+ };
193
+ export type { CmsAdapter, CmsCapabilities, CmsInlineAsset, CmsPublishContext, CmsPublishResult, ResolvedCapabilities } from "../cms/adapter.ts";
194
+ export { jsonFileAdapter, editorApiAdapter, resolveCapabilities } from "../cms/index.ts";
195
+ /**
196
+ * Build a Web-standard request handler that wraps the orchestrator brain.
197
+ *
198
+ * Usage in Next.js App Router (`app/api/avocado/[[...path]]/route.ts`):
199
+ *
200
+ * export const runtime = "nodejs"
201
+ * const handler = createOrchestrator()
202
+ * export const POST = handler
203
+ * export const OPTIONS = handler
204
+ */
205
+ export declare function createOrchestrator(config?: CreateOrchestratorConfig): OrchestratorHandler;