@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.
- package/dist/agent/sites-agent-context.js +3 -2
- package/dist/agent/sites-agent-shared.js +1 -0
- package/dist/chat/anthropic-planner.js +3 -3
- package/dist/chat/chat-pipeline.js +122 -20
- package/dist/chat/gemini-planner.js +3 -3
- package/dist/chat/planner.js +7 -5
- package/dist/chat/prompts.js +6 -1
- package/dist/cms/adapter.d.ts +159 -1
- package/dist/cms/adapter.js +19 -1
- package/dist/cms/bootstrap.d.ts +46 -1
- package/dist/cms/bootstrap.js +126 -2
- package/dist/cms/index.d.ts +3 -2
- package/dist/cms/index.js +2 -1
- package/dist/errors.d.ts +9 -1
- package/dist/handler/auth.d.ts +79 -0
- package/dist/handler/auth.js +113 -0
- package/dist/handler/create-orchestrator.d.ts +205 -0
- package/dist/handler/create-orchestrator.js +1599 -0
- package/dist/http/access-tokens.d.ts +58 -0
- package/dist/http/access-tokens.js +161 -0
- package/dist/http/audio-actions.d.ts +121 -0
- package/dist/http/audio-actions.js +248 -0
- package/dist/http/blocks-actions.d.ts +31 -0
- package/dist/http/blocks-actions.js +31 -0
- package/dist/http/draft-provenance.d.ts +68 -0
- package/dist/http/draft-provenance.js +101 -0
- package/dist/http/history-actions.d.ts +58 -0
- package/dist/http/history-actions.js +169 -0
- package/dist/http/image-generate-actions.d.ts +268 -0
- package/dist/http/image-generate-actions.js +546 -0
- package/dist/http/ops-actions.d.ts +51 -0
- package/dist/http/ops-actions.js +79 -0
- package/dist/http/publish-actions.d.ts +153 -0
- package/dist/http/publish-actions.js +323 -0
- package/dist/http/restore-actions.d.ts +67 -0
- package/dist/http/restore-actions.js +145 -0
- package/dist/http/screenshot-actions.d.ts +108 -0
- package/dist/http/screenshot-actions.js +181 -0
- package/dist/http/session-actions.d.ts +35 -0
- package/dist/http/session-actions.js +98 -0
- package/dist/http/telemetry-feedback-actions.d.ts +53 -0
- package/dist/http/telemetry-feedback-actions.js +68 -0
- package/dist/http/unsplash-actions.d.ts +64 -0
- package/dist/http/unsplash-actions.js +81 -0
- package/dist/http/variations-actions.d.ts +102 -0
- package/dist/http/variations-actions.js +104 -0
- package/dist/index.d.ts +4 -1
- package/dist/index.js +21 -1
- package/dist/nlp/deterministic-planner-refs.d.ts +1 -1
- package/dist/nlp/deterministic-planner-suggestions.d.ts +10 -0
- package/dist/nlp/deterministic-planner-suggestions.js +37 -11
- package/dist/nlp/plan-normalizer.js +18 -2
- package/dist/ops/ops-engine.js +219 -14
- package/dist/state/session-state.d.ts +56 -1
- package/dist/state/session-state.js +92 -6
- package/dist/state/sqlite-store-singleton.d.ts +22 -0
- package/dist/state/sqlite-store-singleton.js +49 -1
- package/dist/state/sqlite-store.d.ts +5 -0
- package/dist/state/sqlite-store.js +125 -2
- package/dist/telemetry/chat-telemetry.js +6 -1
- package/package.json +12 -16
package/dist/cms/bootstrap.js
CHANGED
|
@@ -1,10 +1,39 @@
|
|
|
1
|
-
import {
|
|
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
|
|
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();
|
package/dist/cms/index.d.ts
CHANGED
|
@@ -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;
|