@noodleseed/one 0.160.0 → 0.161.1
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/node_modules/@noodle-borg/agent-kit/dist/generated/example-files.js +3 -3
- package/node_modules/@noodle-borg/agent-kit/dist/skill-embedded-assistant-ref.js +1 -1
- package/node_modules/@noodle-borg/agent-kit/dist/skill-verification-ref.js +1 -1
- package/node_modules/@noodle-borg/agent-kit/package.json +1 -1
- package/node_modules/@noodle-borg/runtime/dist/managed-origins.d.ts +6 -1
- package/node_modules/@noodle-borg/runtime/dist/managed-origins.js +4 -0
- package/node_modules/@noodle-borg/service/dist/registry-compile.js +21 -12
- package/node_modules/@noodle-borg/service/dist/registry-deploy-transaction.js +14 -10
- package/node_modules/@noodle-borg/service/dist/routes/deploy-preflight.js +9 -7
- package/node_modules/@noodle-borg/service/package.json +1 -1
- package/node_modules/@noodleseed/assistant/README.md +15 -8
- package/package.json +2 -2
|
@@ -91,20 +91,20 @@ export const BUNDLED_EXAMPLE_FILES = [
|
|
|
91
91
|
{ relPath: "examples/hello/package.json", content: "{\n \"name\": \"hello\",\n \"version\": \"0.1.0\",\n \"private\": true,\n \"type\": \"module\",\n \"scripts\": {\n \"test\": \"vitest run --dir test\",\n \"validate\": \"noodle validate\",\n \"dev\": \"noodle dev\",\n \"deploy\": \"noodle deploy\"\n },\n \"devDependencies\": {\n \"@noodleseed/one\": \"latest\",\n \"vitest\": \"latest\"\n }\n}\n" },
|
|
92
92
|
{ relPath: "examples/hello/src/server.ts", content: "import { annotations, server, tool, z } from '@noodleseed/one';\n\n// Customer apps stay on the public SDK; @noodle-borg/* packages are runtime implementation details.\n\nexport default server(\n 'hello',\n {\n title: 'Hello',\n version: '1.0.0',\n branding: {\n name: 'Hello',\n accent: '#1D9E75',\n radius: 'md',\n density: 'comfortable',\n },\n },\n [\n tool('greet', {\n // Every model-visible tool declares a title: hosts show it in tool pickers and confirmation\n // prompts, and both consumer directories reject tools without one.\n title: 'Greet someone',\n description: 'Greet someone by name.',\n input: z.object({\n // Defaults are advertised to the model and applied at runtime when the argument is omitted.\n name: z.string().default('world'),\n }),\n output: z.object({\n message: z.string(),\n }),\n // Read-only, closed-world: assistant surfaces run this without a consent prompt.\n annotations: annotations.readOnly(),\n fulfil: ({ input }) => {\n return { message: `Hello, ${input.name}!` };\n },\n }),\n ],\n);\n" },
|
|
93
93
|
{ relPath: "examples/hello/test/server.test.ts", content: "import { describe, expect, it } from 'vitest';\nimport app from '../src/server.js';\n\ndescribe('hello example', () => {\n it('exports a Noodle server definition', () => {\n expect(typeof app.toManifest).toBe('function');\n });\n\n it('advertises the greet default and keeps the argument optional', async () => {\n const manifest = await app.toManifest();\n const greet = manifest.tools?.find((tool) => tool.name === 'greet');\n const schema = greet?.inputSchema as {\n properties?: { name?: { default?: unknown } };\n required?: string[];\n };\n expect(schema.properties?.name?.default).toBe('world');\n expect(schema.required ?? []).not.toContain('name');\n });\n});\n" },
|
|
94
|
-
{ relPath: "examples/stateful-draft/README.md", content: "# Stateful Draft\n\n**Owns:** The flagship for a useful brief before signup, authoritative caller state, and account continuation.\n**Read when:** You want to compose a small conversational onboarding flow from existing platform capabilities.\n**Do not put here:** Customer credentials, a new identity provider, or business-system records.\n**Update when:** The reference tools, state schema, or runnable journey changes.\n\nStart with the visitor's goal. Help them produce something useful before asking for an account. This\nsynthetic example collects a project title, audience, and desired outcome; those fields are illustrative,\nnot requirements for any particular SaaS product.\n\n## The journey\n\n1. The visitor describes a goal. The assistant asks only for missing information and proposes a brief.\n2. `open_draft` opens an editable review and reads authoritative state, including its current revision.\n3. `save_draft` saves the reviewed brief after confirmation. It sends the current revision and the complete\n value to the state API. Failed or missing responses do not appear as successful saves.\n4. The visitor may choose `continue_draft`. This read requires identity, so an anonymous visitor sees the\n sign-in/signup card. Your host application completes its existing login and spends the bound ticket.\n5. The authenticated assistant reads the same adopted draft. No project, subscription, or business record\n is created by signing in.\n\nThe widget's Continue button requests the identity-dependent tool through the conversation. The ordinary\nsignup route should remain available on the embedding page.\n\n## Run the reference\n\nFrom this repository:\n\n```sh\npnpm install\npnpm build\nnoodle validate examples/stateful-draft/src/server.ts\nnoodle test examples/stateful-draft/src/server.ts\nnoodle dev examples/stateful-draft/src/server.ts --org demo --app stateful-draft\n```\n\nThe local runtime and widget preview exercise the typed tools. A complete mixed-assistant journey also\nrequires two host pages and a backend that verifies the user. The declared development origins are\n`http://localhost:3001` for the public page and `http://localhost:3002` for the authenticated page; replace\nthese with your exact deployment origins. The assistant uses `noodleManaged()`, whose hosted availability\nand budget belong to the operator. Local tool tests do not require a model key.\n\nUse the SDK version declared in this example's package file. Older published SDKs may omit the state\nadoption flag when compiling; the example's tests check that the flag reaches the compiled declaration.\n\nUse [signup continuity](https://docs.noodleseed.dev/docs/guides/signup-continuity) for the complete host\nintegration and [customer-auth](../customer-auth/README.md) for customer-owned API authentication.\n\n### Loopback demonstration with a hosted assistant\n\nThe included host serves the public page on port 3001 and a **simulated** account page on port 3002.\nIt binds to loopback and must not be published as a production authentication implementation.\nAfter deploying this app, copy this directory outside the monorepo and run the commands below from that\ncopy. This keeps published dependencies from shadowing the monorepo's workspace SDK.\n\n```sh\npnpm install --ignore-workspace --lockfile=false\nexport NOODLE_EMBED_ID=<embed-id-printed-by-deploy>\npnpm site\n```\n\nThis is enough to test the anonymous conversation and saved brief. To exercise the synthetic account\nhandoff, create an assistant backend client for the same org/app/env with\n`noodle assistant clients create --name first-brief-demo --org <org> --app <app> --env <env> --json`.\nSet `NOODLE_ASSISTANT_CREDENTIALS_FILE` to the returned `secretFile` path and restart `pnpm site`.\nThe host consumes that private file without printing it or sending its contents to the browser.\nOverride `NOODLE_SERVICE_URL` only when deploying to a different hosted service.\n\nThe simulated signup chooses a random temporary demo identity and spends the ticket through the real\nbackend session helper. The host's login transaction lasts ten minutes; it is local process memory and\nis lost on restart. A production integration replaces it with the customer's existing verified login and\nlogin transaction. Do not copy the synthetic identity branch into a real application.\n\n## Customer integration map\n\n| Reference | Adaptation in the customer's application |\n| :--- | :--- |\n| Three-field brief | Select the smallest useful outcome and collect only its missing inputs |\n| Public mixed surface | Mount the public embed on the unauthenticated website with an exact allowlist |\n| Expiring `draft` handle | Keep only temporary, bounded coordination state; omit persistence if unnecessary |\n| `continue_draft` | Trigger the existing signup/login at the point the visitor chooses an account |\n| Host session endpoint | Verify the logged-in user, spend the bound ticket, return the SDK session response |\n| Final business action | Add a typed connector to the existing authorized, idempotent create/update API |\n\nThe final business action belongs in the customer backend. Show the resulting record or its identifier\nonly after that API confirms success. Signup and state adoption alone are not completed onboarding.\nResearch or document parsing can be added later when they remove a demonstrated user burden; they are not\nprerequisites for this reference.\n\n## State and failure behavior\n\nThe draft uses caller scope, a finite 24-hour TTL, and `claimOnAuthentication: true`. Its `v2` schema\nreplaces the earlier title/stage illustration. Widget state is only a display cache. Reads, validation,\nrevision checks, expiry, and persistence belong to the runtime.\n\nA stale edit requires an explicit reload and review before another save. Spending the single-use sign-in\nticket moves only opted-in state to the backend-verified account, preserving its revision and expiry.\nDestination conflicts fail rather than merging two drafts. Abandoned or expired signup leaves the\nanonymous state under its original limits. A 24-hour state TTL does not promise cross-device recovery or\nthat an arbitrary new anonymous visit can recover the conversation.\n\nThe save is a connector-backed side effect on a public/mixed surface, so it has `confirm: true`. Previewing\nthe brief has no side effect and needs neither signup nor confirmation. Do not add a confirmation to each\nconversational answer or treat confirmation as proof of identity.\n\n## Validate before a customer pilot\n\n- Show useful value with no account and without repeating already supplied information.\n- Save, reopen, and edit the actual record; test a stale revision and an unconfirmed save.\n- Verify the same draft after the customer's real signup and login, including cancellation and expiry.\n- Test an existing-account draft conflict and ensure it is not silently overwritten.\n- Ensure signup triggers no unintended business write and account A cannot read account B's draft.\n- Compare onboarding completion and first useful product outcome with the existing flow; count signups\n separately. A demo is not evidence of improved conversion.\n" },
|
|
94
|
+
{ relPath: "examples/stateful-draft/README.md", content: "# Stateful Draft\n\n**Owns:** The flagship for a useful brief before signup, authoritative caller state, and account continuation.\n**Read when:** You want to compose a small conversational onboarding flow from existing platform capabilities.\n**Do not put here:** Customer credentials, a new identity provider, or business-system records.\n**Update when:** The reference tools, state schema, or runnable journey changes.\n\nStart with the visitor's goal. Help them produce something useful before asking for an account. This\nsynthetic example collects a project title, audience, and desired outcome; those fields are illustrative,\nnot requirements for any particular SaaS product.\n\n## The journey\n\n1. The visitor describes a goal. The assistant asks only for missing information and proposes a brief.\n2. `open_draft` opens an editable review and reads authoritative state, including its current revision.\n3. `save_draft` saves the reviewed brief after confirmation. It sends the current revision and the complete\n value to the state API. Failed or missing responses do not appear as successful saves.\n4. The visitor may choose `continue_draft`. This read requires identity, so an anonymous visitor sees the\n sign-in/signup card. Your host application completes its existing login and spends the bound ticket.\n5. The authenticated assistant reads the same adopted draft. No project, subscription, or business record\n is created by signing in.\n\nThe widget's Continue button requests the identity-dependent tool through the conversation. The ordinary\nsignup route should remain available on the embedding page.\n\n## Run the reference\n\nFrom this repository:\n\n```sh\npnpm install\npnpm build\nnoodle validate examples/stateful-draft/src/server.ts\nnoodle test examples/stateful-draft/src/server.ts\nnoodle dev examples/stateful-draft/src/server.ts --org demo --app stateful-draft\n```\n\nThe local runtime and widget preview exercise the typed tools. A complete mixed-assistant journey also\nrequires two host pages and a backend that verifies the user. The declared development origins are\n`http://localhost:3001` for the public page and `http://localhost:3002` for the authenticated page; replace\nthese with your exact deployment origins. The assistant uses `noodleManaged()`, whose hosted availability\nand budget belong to the operator. Local tool tests do not require a model key.\n\nUse the SDK version declared in this example's package file. Older published SDKs may omit the state\nadoption flag when compiling; the example's tests check that the flag reaches the compiled declaration.\n\nUse [signup continuity](https://docs.noodleseed.dev/docs/guides/signup-continuity) for the complete host\nintegration and [customer-auth](../customer-auth/README.md) for customer-owned API authentication.\n\n### Loopback demonstration with a hosted assistant\n\nThe included host serves the public page on port 3001 and a **simulated** account page on port 3002.\nIt binds to loopback and must not be published as a production authentication implementation.\nAfter deploying this app, copy this directory outside the monorepo and run the commands below from that\ncopy. This keeps published dependencies from shadowing the monorepo's workspace SDK.\n\n```sh\npnpm install --ignore-workspace --lockfile=false\nexport NOODLE_EMBED_ID=<embed-id-printed-by-deploy>\npnpm site\n```\n\nThis is enough to test the anonymous conversation and saved brief. To exercise the synthetic account\nhandoff, create an assistant backend client for the same org/app/env with\n`noodle assistant clients create --name first-brief-demo --org <org> --app <app> --env <env> --json`.\nSet `NOODLE_ASSISTANT_CREDENTIALS_FILE` to the returned `secretFile` path and restart `pnpm site`.\nThe host consumes that private file without printing it or sending its contents to the browser.\nOverride `NOODLE_SERVICE_URL` only when deploying to a different hosted service.\n\nThe simulated signup chooses a random temporary demo identity and spends the ticket through the real\nbackend session helper. The host's login transaction lasts ten minutes; it is local process memory and\nis lost on restart. A production integration replaces it with the customer's existing verified login and\nlogin transaction. Do not copy the synthetic identity branch into a real application.\n\n## Customer integration map\n\n| Reference | Adaptation in the customer's application |\n| :--- | :--- |\n| Three-field brief | Select the smallest useful outcome and collect only its missing inputs |\n| Public mixed surface | Mount the public embed on the unauthenticated website with an exact allowlist |\n| Expiring `draft` handle | Keep only temporary, bounded coordination state; omit persistence if unnecessary |\n| `continue_draft` | Trigger the existing signup/login at the point the visitor chooses an account |\n| Host session endpoint | Verify the logged-in user, spend the bound ticket, return the SDK session response |\n| Final business action | Add a typed connector to the existing authorized, idempotent create/update API |\n\nThe final business action belongs in the customer backend. Show the resulting record or its identifier\nonly after that API confirms success. Signup and state adoption alone are not completed onboarding.\nResearch or document parsing can be added later when they remove a demonstrated user burden; they are not\nprerequisites for this reference.\n\n## State and failure behavior\n\nThe draft uses caller scope, a finite 24-hour TTL, and `claimOnAuthentication: true`. Its `v2` schema\nreplaces the earlier title/stage illustration. Widget state is only a display cache. Reads, validation,\nrevision checks, expiry, and persistence belong to the runtime.\n\nA stale edit requires an explicit reload and review before another save. Spending the single-use sign-in\nticket moves only opted-in state to the backend-verified account, preserving its revision and expiry.\nDestination conflicts fail rather than merging two drafts. Abandoned or expired signup leaves the\nanonymous state under its original limits. A 24-hour state TTL does not promise cross-device recovery or\nthat an arbitrary new anonymous visit can recover the conversation.\n\nThe save is a connector-backed side effect on a public/mixed surface, so it has `confirm: true`. Previewing\nthe brief has no side effect and needs neither signup nor confirmation. Do not add a confirmation to each\nconversational answer or treat confirmation as proof of identity.\nThe example relies on the managed confirmation default: reviewers see the complete business brief and\ndecision controls without connector mechanics. Enable `showConfirmationDetails` only for an audience that\nneeds those technical details.\n\n## Validate before a customer pilot\n\n- Show useful value with no account and without repeating already supplied information.\n- Save, reopen, and edit the actual record; test a stale revision and an unconfirmed save.\n- Verify the same draft after the customer's real signup and login, including cancellation and expiry.\n- Test an existing-account draft conflict and ensure it is not silently overwritten.\n- Ensure signup triggers no unintended business write and account A cannot read account B's draft.\n- Compare onboarding completion and first useful product outcome with the existing flow; count signups\n separately. A demo is not evidence of improved conversion.\n" },
|
|
95
95
|
{ relPath: "examples/stateful-draft/noodle.json", content: "{\n \"entrypoint\": \"src/server.ts\",\n \"name\": \"stateful-draft\",\n \"template\": \"widget\"\n}\n" },
|
|
96
96
|
{ relPath: "examples/stateful-draft/package.json", content: "{\n \"name\": \"stateful-draft\",\n \"version\": \"0.1.0\",\n \"private\": true,\n \"type\": \"module\",\n \"scripts\": {\n \"test\": \"vitest run\",\n \"validate\": \"noodle validate\",\n \"dev\": \"noodle dev\",\n \"deploy\": \"noodle deploy\",\n \"site\": \"node site/demo.mjs\"\n },\n \"devDependencies\": {\n \"@noodleseed/assistant\": \"^1.33.0\",\n \"@vitejs/plugin-react\": \"latest\",\n \"@noodleseed/one\": \"^0.154.0\",\n \"react\": \"latest\",\n \"react-dom\": \"latest\",\n \"vite\": \"latest\",\n \"vitest\": \"latest\"\n }\n}\n" },
|
|
97
97
|
{ relPath: "examples/stateful-draft/site/client.js", content: "import '/sdk/index.js';\n\nconst config = await fetch('/demo-config').then((response) => response.json());\nconst mount = document.querySelector('#assistant');\nconst status = document.querySelector('#status');\nif (config.authenticated && !config.signedIn) {\n document.querySelector('#demo-login').hidden = false;\n status.textContent =\n 'Your draft is ready to come with you. This next step simulates the product’s signup.';\n} else {\n const assistant = document.createElement('noodle-assistant');\n assistant.setAttribute('theme', 'light');\n if (config.authenticated) {\n assistant.setAttribute('session-endpoint', '/assistant-session');\n status.textContent =\n 'You are using a synthetic demo account. Open the chat to continue with your saved brief.';\n } else {\n assistant.setAttribute('embed-id', config.embedId);\n assistant.setAttribute('service-url', config.serviceUrl);\n status.textContent =\n 'Start without an account. Open the chat and describe what you want to achieve.';\n }\n mount.append(assistant);\n}\n\ndocument.addEventListener('assistant-sign-in-requested', async (event) => {\n status.textContent = 'Preparing your account handoff…';\n try {\n const response = await fetch('/start', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ signInTicket: event.detail.signInTicket }),\n });\n const result = await response.json();\n if (!response.ok) {\n status.textContent = result.error ?? 'Handoff not started';\n return;\n }\n window.location.assign(result.destination);\n } catch {\n status.textContent =\n 'The handoff did not start. Your brief has not been moved. Please try again.';\n }\n});\n" },
|
|
98
98
|
{ relPath: "examples/stateful-draft/site/demo.mjs", content: "/** Loopback-only synthetic host. Replace its demo identity with your existing login in a real app. */\nimport { randomUUID } from 'node:crypto';\nimport { readFile } from 'node:fs/promises';\nimport { createServer } from 'node:http';\nimport { dirname, join } from 'node:path';\nimport { fileURLToPath } from 'node:url';\nimport { createAssistantSession } from '@noodleseed/assistant/server';\n\nconst publicOrigin = 'http://localhost:3001';\nconst appOrigin = 'http://localhost:3002';\nconst serviceUrl = process.env.NOODLE_SERVICE_URL ?? 'https://cloud.noodleseed.dev';\nconst embedId = process.env.NOODLE_EMBED_ID;\nconst credentialFile = process.env.NOODLE_ASSISTANT_CREDENTIALS_FILE;\nif (!embedId?.match(/^pub_[a-z0-9]+$/i)) {\n throw new Error('Set NOODLE_EMBED_ID from the hosted deployment.');\n}\nif (new URL(serviceUrl).protocol !== 'https:')\n throw new Error('The hosted service must use HTTPS.');\n// Consume only the explicitly supplied backend credential file. Its content is never sent to the browser.\nconst credentials = credentialFile ? JSON.parse(await readFile(credentialFile, 'utf8')) : undefined;\nif (\n credentials &&\n (typeof credentials.clientId !== 'string' || typeof credentials.clientSecret !== 'string')\n) {\n throw new Error('The backend credential file is not a CLI-created assistant client file.');\n}\nconst sdkDir = dirname(fileURLToPath(import.meta.resolve('@noodleseed/assistant')));\nconst siteDir = dirname(fileURLToPath(import.meta.url));\nconst transactions = new Map();\nconst lifetime = 10 * 60 * 1000;\n\nfunction send(res, status, value, type = 'application/json') {\n res.writeHead(status, {\n 'Content-Type': type,\n 'Cache-Control': 'no-store',\n 'Referrer-Policy': 'no-referrer',\n 'X-Content-Type-Options': 'nosniff',\n });\n res.end(type === 'application/json' ? JSON.stringify(value) : value);\n}\nfunction redirect(res, destination) {\n res.writeHead(303, { Location: destination, 'Cache-Control': 'no-store' });\n res.end();\n}\nfunction current(req) {\n const id = /(?:^|;\\s*)brief_demo=([a-f0-9-]+)/.exec(req.headers.cookie ?? '')?.[1];\n return id ? transactions.get(id) : undefined;\n}\nasync function jsonBody(req) {\n let text = '';\n for await (const chunk of req) {\n text += chunk.toString();\n if (Buffer.byteLength(text) > 8192) throw new Error('Request too large');\n }\n return JSON.parse(text);\n}\n\nfunction handler(origin) {\n return async (req, res) => {\n // The fixed loopback host is intentional: never deploy this synthetic-login server to the internet.\n if (req.headers.host !== new URL(origin).host) return send(res, 403, { error: 'Wrong host' });\n for (const [id, transaction] of transactions) {\n if (transaction.expiresAt <= Date.now()) transactions.delete(id);\n }\n const path = new URL(req.url ?? '/', origin).pathname;\n try {\n if (req.method === 'GET' && path === '/') {\n return send(\n res,\n 200,\n await readFile(join(siteDir, 'index.html')),\n 'text/html; charset=utf-8',\n );\n }\n if (req.method === 'GET' && path === '/demo-config') {\n return send(res, 200, {\n serviceUrl,\n embedId,\n authenticated: origin === appOrigin,\n signedIn: Boolean(current(req)?.user),\n });\n }\n if (req.method === 'GET' && path === '/client.js') {\n return send(res, 200, await readFile(join(siteDir, 'client.js')), 'text/javascript');\n }\n if (req.method === 'GET' && /^\\/sdk\\/[a-z0-9._-]+\\.js$/i.test(path)) {\n return send(res, 200, await readFile(join(sdkDir, path.slice(5))), 'text/javascript');\n }\n if (req.method === 'POST') {\n if (req.headers.origin !== origin) return send(res, 403, { error: 'Wrong origin' });\n if (origin === publicOrigin && path === '/start') {\n if (!credentials)\n return send(res, 503, {\n error:\n 'Signup demonstration needs an authorized backend client. The anonymous brief is still available.',\n });\n if (transactions.size >= 40) return send(res, 429, { error: 'Please try again later' });\n const body = await jsonBody(req);\n if (typeof body.signInTicket !== 'string' || body.signInTicket.length > 4096) {\n return send(res, 400, { error: 'Missing continuation ticket' });\n }\n const id = randomUUID();\n transactions.set(id, {\n signInTicket: body.signInTicket,\n expiresAt: Date.now() + lifetime,\n });\n res.setHeader(\n 'Set-Cookie',\n `brief_demo=${id}; HttpOnly; SameSite=Lax; Path=/; Max-Age=600`,\n );\n return send(res, 200, { destination: appOrigin });\n }\n if (origin === appOrigin && path === '/demo-login') {\n const transaction = current(req);\n if (!transaction)\n return send(res, 410, { error: 'Demo expired. Start again on the public page.' });\n // Synthetic identity only. A production route must verify its existing application session.\n transaction.user ??= { id: `demo_${randomUUID()}`, name: 'Demo visitor' };\n return redirect(res, '/');\n }\n }\n if (origin === appOrigin && req.method === 'GET' && path === '/assistant-session') {\n if (!credentials) return send(res, 503, { error: 'Configure the backend client first' });\n if (req.headers['sec-fetch-site'] !== 'same-origin')\n return send(res, 403, { error: 'Same-origin request required' });\n const transaction = current(req);\n if (!transaction?.user)\n return send(res, 401, { error: 'Choose the synthetic demo account first' });\n // Reuse the in-flight result so concurrent mounts cannot spend the single-use ticket twice.\n transaction.session ??= createAssistantSession({\n serviceUrl,\n clientId: credentials.clientId,\n clientSecret: credentials.clientSecret,\n origin: appOrigin,\n user: transaction.user,\n signInTicket: transaction.signInTicket,\n });\n const session = await transaction.session;\n delete transaction.signInTicket;\n return send(res, 200, session);\n }\n return send(res, 404, { error: 'Not found' });\n } catch {\n // Do not log credentials, tickets, requests, or raw provider errors.\n return send(res, 409, {\n error: 'Continuation was not completed. Return to the public page and start again.',\n });\n }\n };\n}\nfor (const [port, origin] of [\n [3001, publicOrigin],\n [3002, appOrigin],\n]) {\n createServer(handler(origin)).listen(port, '127.0.0.1');\n}\nconsole.log(`Synthetic onboarding demo: ${publicOrigin}`);\nconsole.log('Loopback only. Uses the hosted assistant; demo signup is not a real account.');\n" },
|
|
99
99
|
{ relPath: "examples/stateful-draft/site/index.html", content: "<!doctype html>\n<html lang=\"en\">\n <head>\n <meta charset=\"utf-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n <title>First Brief · Conversational onboarding demo</title>\n <style>\n :root { font-family: system-ui, sans-serif; color: #172033; background: #f6f8fc; }\n body { margin: 0; }\n main { max-width: 880px; margin: 0 auto; padding: 64px 28px; }\n .label { font-size: 12px; font-weight: 700; letter-spacing: .14em; color: #526077; }\n h1 { max-width: 640px; font-size: clamp(36px, 6vw, 64px); line-height: 1.08; letter-spacing: -.055em; }\n p { max-width: 570px; line-height: 1.6; color: #526077; }\n .sample { padding: 24px; border: 1px solid #dce3ee; border-radius: 16px; background: white; margin: 28px 0; max-width: 550px; }\n a { color: #174bbd; }\n button { border: none; background: #2563eb; color: white; padding: 14px 20px; border-radius: 9px; font: inherit; cursor: pointer; }\n :focus-visible { outline: 3px solid #2563eb; outline-offset: 4px; }\n .notice { font-size: 13px; }\n </style>\n </head>\n <body>\n <main>\n <span class=\"label\">FIRST BRIEF · INTERNAL DEMONSTRATION</span>\n <h1>Make something useful.<br>Then decide to sign up.</h1>\n <p>Describe a goal. Refine a short brief. See the result before deciding whether to take it into an account.</p>\n <div class=\"sample\">\n <strong>Try this in the chat</strong>\n <p>“I want to help new teammates complete their first useful project in a week.”</p>\n <p id=\"status\" role=\"status\" aria-live=\"polite\">Connecting…</p>\n </div>\n <form id=\"demo-login\" action=\"/demo-login\" method=\"post\" hidden>\n <p><strong>Simulated signup</strong><br>This creates a temporary demo identity. It does not register an account with any SaaS product.</p>\n <button type=\"submit\">Continue as a demo user</button>\n <p><a href=\"http://localhost:3001\">Cancel and return to the public page</a></p>\n </form>\n <p class=\"notice\">Use synthetic information only. The assistant and draft storage are hosted by Noodle Seed. Signup is simulated locally; no customer backend is connected. Drafts expire within 24 hours. <a href=\"https://noodleseed.com/privacy\" target=\"_blank\" rel=\"noreferrer\">Privacy</a></p>\n </main>\n <div id=\"assistant\"></div>\n <script type=\"module\" src=\"/client.js\"></script>\n </body>\n</html>\n" },
|
|
100
100
|
{ relPath: "examples/stateful-draft/src/helpers.ts", content: "import type { ServerDefinition } from '@noodleseed/one';\nimport { generateHelpers } from '@noodleseed/one/react';\n\nexport type AppType = ServerDefinition;\n\nexport const { useCallTool, useToolInfo, useSendFollowUpMessage } = generateHelpers<AppType>();\n" },
|
|
101
|
-
{ relPath: "examples/stateful-draft/src/server.ts", content: "import {\n annotations,\n authenticatedWebsite,\n connector,\n embeddedAssistant,\n noodleManaged,\n publicWebsite,\n server,\n tool,\n z,\n} from '@noodleseed/one';\n\nconst draft = z.object({\n title: z.string().trim().min(1).max(120).optional(),\n audience: z.string().trim().min(1).max(240).optional(),\n goal: z.string().trim().min(1).max(240).optional(),\n});\n\nconst snapshot = z.object({\n value: draft,\n revision: z.number().int(),\n status: z.string(),\n});\n\nconst state = connector('noodle_state')\n .version('1.0.0')\n .operation('read_state', {\n type: 'read',\n input: z.object({ handle: z.string() }),\n output: snapshot,\n })\n .operation('patch_state', {\n type: 'action',\n input: z.object({\n handle: z.string(),\n expectedRevision: z.number().int().min(0),\n value: draft,\n }),\n output: snapshot,\n });\n\nconst openDraft = tool('open_draft', {\n title: 'Review your brief',\n description:\n 'Preview a proposed brief and read the saved brief with its revision. Pass the proposed title, audience, and goal in proposal to prefill the editable preview without saving it. Omit proposal to reload the saved record after a conflict.',\n input: z.object({ proposal: draft.default({}) }),\n output: snapshot.extend({ proposal: draft }),\n fulfil: ({ input, connectors }) => {\n const saved = connectors.state.readState({ handle: 'draft' });\n return {\n value: saved.value,\n revision: saved.revision,\n status: saved.status,\n proposal: input.proposal,\n };\n },\n viewTitle: 'Your project brief',\n invoking: 'Loading your saved brief…',\n invoked: 'Your brief is ready to review',\n view: { component: 'draft-card', entry: './views/draft-card.tsx' },\n annotations: annotations.readOnly(),\n viewDescription: 'Review and edit a saved brief before deciding whether to create an account.',\n csp: { connectDomains: [], resourceDomains: [], frameDomains: [] },\n});\n\nconst saveDraft = tool('save_draft', {\n title: 'Save your brief',\n description:\n 'Save this reviewed brief for up to 24 hours. This does not create an account or a project.',\n input: draft.required().extend({\n expectedRevision: z.number().int().min(0).meta({ title: 'Saved version' }),\n }),\n output: snapshot,\n annotations: annotations.localAction({ destructive: false, confirm: true }),\n fulfil: ({ input, connectors }) => {\n const saved = connectors.state.patchState({\n handle: 'draft',\n expectedRevision: input.expectedRevision,\n value: { title: input.title, audience: input.audience, goal: input.goal },\n });\n return { value: saved.value, revision: saved.revision, status: saved.status };\n },\n});\n\nconst continueDraft = tool('continue_draft', {\n title: 'Continue with an account',\n description:\n 'Read the brief in the signed-in account. Call only when the visitor chooses to continue with an account, after showing useful value. Sign-in carries the saved draft forward; it does not submit, publish, or create a project.',\n input: z.object({}),\n output: snapshot.extend({ accountId: z.string() }),\n annotations: annotations.readOnly(),\n fulfil: ({ connectors, user }) => {\n const saved = connectors.state.readState({ handle: 'draft' });\n return {\n value: saved.value,\n revision: saved.revision,\n status: saved.status,\n accountId: user.id,\n };\n },\n});\n\nexport default server(\n 'stateful_draft',\n {\n title: 'Your first useful brief',\n version: '1.0.0',\n instructions:\n 'Help someone turn their goal into a concise project brief: a title, audience, and desired outcome. Base the brief on information they supply. Propose a sensible editable title; never require a naming question. If the audience and outcome are already clear, immediately show the complete proposed brief. Otherwise ask one short question for missing information and skip anything already answered. Show the useful proposed brief in chat before offering to save it. Do not request an email, password, document, or external research. Read open_draft before save_draft. Saving requires review and confirmation; an unconfirmed save is not persisted. Never claim an account or project was created. After saving, offer continue_draft only as an optional next step; never require signup to see the brief. This is a synthetic onboarding example, not an integration with a specific SaaS product.',\n branding: {\n name: 'First Brief',\n accent: '#2563EB',\n surface: '#F8FAFC',\n surfaceDark: '#111827',\n radius: 'md',\n density: 'comfortable',\n typography: 'system',\n colorScheme: 'auto',\n },\n use: { state },\n state: {\n handles: {\n draft: {\n kind: 'draft',\n version: 'v2',\n scope: 'caller',\n ttlSeconds: 86400,\n claimOnAuthentication: true,\n schema: draft,\n },\n },\n },\n assistant: embeddedAssistant({\n model: noodleManaged(),\n privacyUrl: 'https://noodleseed.com/privacy',\n
|
|
101
|
+
{ relPath: "examples/stateful-draft/src/server.ts", content: "import {\n annotations,\n authenticatedWebsite,\n connector,\n embeddedAssistant,\n noodleManaged,\n publicWebsite,\n server,\n tool,\n z,\n} from '@noodleseed/one';\n\nconst draft = z.object({\n title: z.string().trim().min(1).max(120).optional(),\n audience: z.string().trim().min(1).max(240).optional(),\n goal: z.string().trim().min(1).max(240).optional(),\n});\n\nconst snapshot = z.object({\n value: draft,\n revision: z.number().int(),\n status: z.string(),\n});\n\nconst state = connector('noodle_state')\n .version('1.0.0')\n .operation('read_state', {\n type: 'read',\n input: z.object({ handle: z.string() }),\n output: snapshot,\n })\n .operation('patch_state', {\n type: 'action',\n input: z.object({\n handle: z.string(),\n expectedRevision: z.number().int().min(0),\n value: draft,\n }),\n output: snapshot,\n });\n\nconst openDraft = tool('open_draft', {\n title: 'Review your brief',\n description:\n 'Preview a proposed brief and read the saved brief with its revision. Pass the proposed title, audience, and goal in proposal to prefill the editable preview without saving it. Omit proposal to reload the saved record after a conflict.',\n input: z.object({ proposal: draft.default({}) }),\n output: snapshot.extend({ proposal: draft }),\n fulfil: ({ input, connectors }) => {\n const saved = connectors.state.readState({ handle: 'draft' });\n return {\n value: saved.value,\n revision: saved.revision,\n status: saved.status,\n proposal: input.proposal,\n };\n },\n viewTitle: 'Your project brief',\n invoking: 'Loading your saved brief…',\n invoked: 'Your brief is ready to review',\n view: { component: 'draft-card', entry: './views/draft-card.tsx' },\n annotations: annotations.readOnly(),\n viewDescription: 'Review and edit a saved brief before deciding whether to create an account.',\n csp: { connectDomains: [], resourceDomains: [], frameDomains: [] },\n});\n\nconst saveDraft = tool('save_draft', {\n title: 'Save your brief',\n description:\n 'Save this reviewed brief for up to 24 hours. This does not create an account or a project.',\n input: draft.required().extend({\n expectedRevision: z.number().int().min(0).meta({ title: 'Saved version' }),\n }),\n output: snapshot,\n annotations: annotations.localAction({ destructive: false, confirm: true }),\n fulfil: ({ input, connectors }) => {\n const saved = connectors.state.patchState({\n handle: 'draft',\n expectedRevision: input.expectedRevision,\n value: { title: input.title, audience: input.audience, goal: input.goal },\n });\n return { value: saved.value, revision: saved.revision, status: saved.status };\n },\n});\n\nconst continueDraft = tool('continue_draft', {\n title: 'Continue with an account',\n description:\n 'Read the brief in the signed-in account. Call only when the visitor chooses to continue with an account, after showing useful value. Sign-in carries the saved draft forward; it does not submit, publish, or create a project.',\n input: z.object({}),\n output: snapshot.extend({ accountId: z.string() }),\n annotations: annotations.readOnly(),\n fulfil: ({ connectors, user }) => {\n const saved = connectors.state.readState({ handle: 'draft' });\n return {\n value: saved.value,\n revision: saved.revision,\n status: saved.status,\n accountId: user.id,\n };\n },\n});\n\nexport default server(\n 'stateful_draft',\n {\n title: 'Your first useful brief',\n version: '1.0.0',\n instructions:\n 'Help someone turn their goal into a concise project brief: a title, audience, and desired outcome. Base the brief on information they supply. Propose a sensible editable title; never require a naming question. If the audience and outcome are already clear, immediately show the complete proposed brief. Otherwise ask one short question for missing information and skip anything already answered. Show the useful proposed brief in chat before offering to save it. Do not request an email, password, document, or external research. Read open_draft before save_draft. Saving requires review and confirmation; an unconfirmed save is not persisted. Never claim an account or project was created. After saving, offer continue_draft only as an optional next step; never require signup to see the brief. This is a synthetic onboarding example, not an integration with a specific SaaS product.',\n branding: {\n name: 'First Brief',\n accent: '#2563EB',\n surface: '#F8FAFC',\n surfaceDark: '#111827',\n radius: 'md',\n density: 'comfortable',\n typography: 'system',\n colorScheme: 'auto',\n },\n use: { state },\n state: {\n handles: {\n draft: {\n kind: 'draft',\n version: 'v2',\n scope: 'caller',\n ttlSeconds: 86400,\n claimOnAuthentication: true,\n schema: draft,\n },\n },\n },\n assistant: embeddedAssistant({\n model: noodleManaged(),\n privacyUrl: 'https://noodleseed.com/privacy',\n access: [\n publicWebsite({\n origins: ['http://localhost:3001'],\n capabilities: [openDraft, saveDraft, continueDraft],\n signIn: true,\n }),\n authenticatedWebsite({\n origins: ['http://localhost:3002'],\n capabilities: [openDraft, saveDraft, continueDraft],\n }),\n ],\n labels: {\n welcomeHeading: 'What would you like to achieve?',\n composerPlaceholder: 'Describe your goal…',\n signInHeading: 'Take your brief with you',\n signInBody: 'Continue with an account to use your saved brief inside the product.',\n signInAction: 'Sign in',\n signUpAction: 'Create account',\n },\n }),\n },\n [openDraft, saveDraft, continueDraft],\n);\n" },
|
|
102
102
|
{ relPath: "examples/stateful-draft/src/views/draft-card.tsx", content: "import { useEffect, useState } from 'react';\nimport { useCallTool, useSendFollowUpMessage, useToolInfo } from '../helpers.js';\nimport './widget-style.css';\n\ntype Brief = { title?: string; audience?: string; goal?: string };\ntype Snapshot = { value: Brief; revision: number; status: string; proposal?: Brief };\n\nfunction displayBrief(data: Snapshot | undefined): Brief {\n const proposal = data?.proposal;\n return proposal && (proposal.title || proposal.audience || proposal.goal)\n ? proposal\n : (data?.value ?? {});\n}\n\nfunction snapshot(result: unknown): Snapshot | undefined {\n if (!result || typeof result !== 'object') return;\n const envelope = result as { isError?: boolean; structuredContent?: Partial<Snapshot> };\n const data = envelope.structuredContent;\n if (\n !envelope.isError &&\n data &&\n data.value &&\n typeof data.value === 'object' &&\n Number.isInteger(data.revision) &&\n typeof data.status === 'string'\n ) {\n return data as Snapshot;\n }\n}\n\nexport default function DraftCard() {\n const info = useToolInfo('open_draft');\n const read = useCallTool('open_draft');\n const save = useCallTool('save_draft');\n const followUp = useSendFollowUpMessage();\n const [saved, setSaved] = useState(() => snapshot(info));\n const [brief, setBrief] = useState<Brief>(() => displayBrief(snapshot(info)));\n const [busy, setBusy] = useState(false);\n const [reloadRequired, setReloadRequired] = useState(false);\n const [message, setMessage] = useState('Review your brief. An account is optional.');\n useEffect(() => {\n const next = snapshot({ structuredContent: info.structuredContent });\n if (next) {\n setSaved(next);\n setBrief(displayBrief(next));\n }\n }, [info.structuredContent]);\n const complete = Boolean(brief.title?.trim() && brief.audience?.trim() && brief.goal?.trim());\n const unchanged = ['title', 'audience', 'goal'].every(\n (key) => brief[key as keyof Brief] === saved?.value[key as keyof Brief],\n );\n\n async function run(operation: 'load' | 'save') {\n setBusy(true);\n try {\n const result =\n operation === 'load'\n ? await read.callTool({})\n : await save.callTool({ ...brief, expectedRevision: saved?.revision });\n const next = snapshot(result);\n if (!next) throw new Error('No authoritative result');\n setSaved(next);\n setBrief(displayBrief(next));\n setReloadRequired(false);\n setMessage(operation === 'save' ? 'Your brief is saved.' : 'Saved brief loaded.');\n } catch {\n setReloadRequired(true);\n setMessage(\n 'No save was confirmed. Reload saved to check the latest brief before trying again.',\n );\n } finally {\n setBusy(false);\n }\n }\n\n return (\n <main className=\"brief-shell\">\n <header>\n <p className=\"brief-eyebrow\">YOUR FIRST USEFUL STEP</p>\n <h1>Your project brief</h1>\n <p>Make something useful before creating an account.</p>\n </header>\n <section className=\"brief-fields\">\n <label>\n Project title\n <input\n required\n maxLength={120}\n value={brief.title ?? ''}\n placeholder=\"A better first week\"\n onChange={(event) => setBrief({ ...brief, title: event.currentTarget.value })}\n />\n </label>\n <label>\n Who is this for?\n <textarea\n required\n maxLength={240}\n rows={2}\n value={brief.audience ?? ''}\n placeholder=\"New teammates joining our product team\"\n onChange={(event) => setBrief({ ...brief, audience: event.currentTarget.value })}\n />\n </label>\n <label>\n What would success look like?\n <textarea\n required\n maxLength={240}\n rows={3}\n value={brief.goal ?? ''}\n placeholder=\"Complete their first useful project in a week\"\n onChange={(event) => setBrief({ ...brief, goal: event.currentTarget.value })}\n />\n </label>\n <p role=\"status\" aria-live=\"polite\">\n {busy ? 'Waiting for the result…' : message}\n </p>\n <div className=\"brief-actions\">\n <button type=\"button\" disabled={busy} onClick={() => void run('load')}>\n Reload saved\n </button>\n <button\n className=\"brief-primary\"\n type=\"button\"\n onClick={() => void run('save')}\n disabled={busy || !saved || !complete || reloadRequired}\n >\n Save brief\n </button>\n </div>\n </section>\n <footer>\n <p>\n A saved brief lasts up to 24 hours and can follow you when you sign in. No project has\n been created.\n </p>\n <button\n type=\"button\"\n disabled={\n busy || !complete || !unchanged || !saved || saved.revision === 0 || reloadRequired\n }\n onClick={() =>\n void followUp({ prompt: 'I would like to continue with my saved brief in an account.' })\n }\n >\n Continue with an account\n </button>\n </footer>\n </main>\n );\n}\n" },
|
|
103
103
|
{ relPath: "examples/stateful-draft/src/views/widget-style.css", content: "@layer stateful-draft {\n .brief-shell {\n box-sizing: border-box;\n max-width: 600px;\n margin: 0 auto;\n padding: 24px;\n color: var(--ns-text-primary, #172033);\n background: var(--ns-surface, #f8fafc);\n font-family: var(--ns-font, system-ui, sans-serif);\n line-height: 1.5;\n }\n .brief-shell * {\n box-sizing: border-box;\n }\n .brief-shell h1 {\n margin: 4px 0;\n font-size: 26px;\n letter-spacing: -0.03em;\n }\n .brief-shell p {\n color: var(--ns-text-secondary, #526077);\n }\n .brief-eyebrow {\n font-size: 11px;\n font-weight: 700;\n letter-spacing: 0.12em;\n }\n .brief-fields {\n display: grid;\n gap: 16px;\n margin: 24px 0;\n }\n .brief-shell label {\n display: grid;\n gap: 6px;\n font-size: 14px;\n font-weight: 600;\n }\n .brief-shell input,\n .brief-shell textarea {\n width: 100%;\n padding: 10px 12px;\n border: 1px solid var(--ns-border, #cbd5e1);\n border-radius: var(--ns-radius-md, 8px);\n color: inherit;\n background: var(--ns-surface-raised, #fff);\n font: inherit;\n font-weight: 400;\n resize: vertical;\n }\n .brief-actions {\n display: flex;\n flex-wrap: wrap;\n gap: 8px;\n }\n .brief-shell button {\n padding: 10px 16px;\n border: 1px solid var(--ns-border, #cbd5e1);\n border-radius: var(--ns-radius-md, 8px);\n background: var(--ns-surface-raised, #fff);\n color: inherit;\n font: inherit;\n font-size: 14px;\n font-weight: 600;\n cursor: pointer;\n }\n .brief-shell .brief-primary {\n background: var(--ns-accent, #2563eb);\n color: var(--ns-accent-text, #fff);\n border-color: transparent;\n }\n .brief-shell button:disabled {\n opacity: 0.5;\n cursor: default;\n }\n .brief-shell :focus-visible {\n outline: 2px solid var(--ns-focus, #2563eb);\n outline-offset: 3px;\n }\n .brief-shell footer {\n border-top: 1px solid var(--ns-border, #cbd5e1);\n padding-top: 12px;\n font-size: 13px;\n }\n @media (max-width: 380px) {\n .brief-shell {\n padding: 16px;\n }\n .brief-actions button {\n width: 100%;\n }\n }\n}\n" },
|
|
104
104
|
{ relPath: "examples/stateful-draft/test/draft-card.test.tsx", content: "// @vitest-environment happy-dom\nimport { act } from 'react';\nimport { createRoot } from 'react-dom/client';\nimport { afterEach, beforeEach, expect, it, vi } from 'vitest';\n\nconst callTool = vi.fn();\nconst followUp = vi.fn();\nconst initial = {\n value: { title: 'Team launch', audience: 'New teammates', goal: 'Complete their first project' },\n revision: 4,\n status: 'active',\n};\nlet entry: unknown = initial;\nvi.mock('../src/helpers.js', () => ({\n useToolInfo: () => ({ structuredContent: entry }),\n useCallTool: () => ({ callTool }),\n useSendFollowUpMessage: () => followUp,\n}));\n\nimport DraftCard from '../src/views/draft-card.js';\n\nlet host: HTMLDivElement;\nlet root: ReturnType<typeof createRoot>;\nbeforeEach(async () => {\n vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);\n callTool.mockReset();\n followUp.mockReset();\n entry = initial;\n host = document.createElement('div');\n document.body.append(host);\n root = createRoot(host);\n await act(async () => root.render(<DraftCard />));\n});\nafterEach(() => {\n act(() => root.unmount());\n host.remove();\n});\nfunction button(label: string) {\n const found = [...host.querySelectorAll('button')].find((entry) => entry.textContent === label);\n if (!found) throw new Error(`Missing button: ${label}`);\n return found;\n}\n\nit('saves using the server revision and displays only the returned result', async () => {\n callTool.mockResolvedValue({ structuredContent: { ...initial, revision: 8 } });\n await act(async () => button('Save brief').click());\n expect(callTool).toHaveBeenCalledWith({ ...initial.value, expectedRevision: 4 });\n expect(host.textContent).toContain('Your brief is saved.');\n expect(button('Continue with an account').disabled).toBe(false);\n});\n\nit('shows a proposed brief without pretending it is already saved', async () => {\n entry = { value: {}, revision: 0, status: 'active', proposal: initial.value };\n await act(async () => root.render(<DraftCard />));\n expect(host.querySelector('input')?.value).toBe('Team launch');\n expect(button('Continue with an account').disabled).toBe(true);\n callTool.mockResolvedValue({ structuredContent: { ...initial, revision: 1 } });\n await act(async () => button('Save brief').click());\n expect(callTool).toHaveBeenCalledWith({ ...initial.value, expectedRevision: 0 });\n});\n\nit('does not invent a save when confirmation is pending or the response is missing', async () => {\n callTool.mockResolvedValue({});\n await act(async () => button('Save brief').click());\n expect(host.textContent).not.toContain('Your brief is saved.');\n expect(host.textContent).toContain('No save was confirmed.');\n});\n\nit('retains edits on a stale write and requires a reload before another save', async () => {\n callTool.mockResolvedValue({ isError: true });\n await act(async () => button('Save brief').click());\n expect(host.querySelector('input')?.value).toBe('Team launch');\n expect(button('Save brief').disabled).toBe(true);\n expect(host.textContent).toContain('Reload saved');\n callTool.mockResolvedValue({ structuredContent: { ...initial, revision: 7 } });\n await act(async () => button('Reload saved').click());\n await act(async () => button('Save brief').click());\n expect(callTool).toHaveBeenLastCalledWith({ ...initial.value, expectedRevision: 7 });\n});\n\nit('keeps continuing separate from saving and makes no project-creation claim', async () => {\n await act(async () => button('Continue with an account').click());\n expect(callTool).not.toHaveBeenCalled();\n expect(followUp).toHaveBeenCalledWith({\n prompt: 'I would like to continue with my saved brief in an account.',\n });\n expect(host.textContent).not.toContain('Project created');\n});\n" },
|
|
105
105
|
{ relPath: "examples/stateful-draft/test/server.test.ts", content: "import { fileURLToPath } from 'node:url';\nimport { validate } from '@noodleseed/one';\nimport { describe, expect, it } from 'vitest';\nimport app from '../src/server.js';\n\ndescribe('stateful draft onboarding reference', () => {\n it('reads and saves authoritative state instead of a widget-only copy', async () => {\n const manifest = await app.toManifest();\n expect(manifest.tools.find((entry) => entry.name === 'open_draft')?.fulfilment.steps).toEqual([\n expect.objectContaining({ use: 'state.read_state', args: { handle: 'draft' } }),\n ]);\n expect(manifest.tools.find((entry) => entry.name === 'save_draft')).toMatchObject({\n annotations: { readOnlyHint: false, confirm: true },\n fulfilment: {\n steps: [\n expect.objectContaining({\n use: 'state.patch_state',\n args: {\n handle: 'draft',\n expectedRevision: '${input.expectedRevision}',\n value: {\n title: '${input.title}',\n audience: '${input.audience}',\n goal: '${input.goal}',\n },\n },\n }),\n ],\n },\n });\n });\n\n it('limits anonymous access and transfers only an expiring draft after verified login', async () => {\n const manifest = await app.toManifest();\n expect(manifest.state?.handles.draft).toMatchObject({\n scope: 'caller',\n ttlSeconds: 86400,\n claimOnAuthentication: true,\n });\n expect(manifest.server.assistant?.surfaces?.map((surface) => surface.mode)).toEqual([\n 'mixed',\n 'authenticated',\n ]);\n const continued = manifest.tools.find((entry) => entry.name === 'continue_draft');\n expect(continued?.annotations?.readOnlyHint).toBe(true);\n expect(continued?.fulfilment.output).toMatchObject({ accountId: '${user.id}' });\n expect(continued?.fulfilment.steps).toEqual([\n expect.objectContaining({ use: 'state.read_state', args: { handle: 'draft' } }),\n ]);\n });\n\n it('compiles through the public validator, including anonymous action confirmation', async () => {\n const result = await validate({\n manifestPath: fileURLToPath(new URL('../src/server.ts', import.meta.url)),\n });\n expect(result.ok, JSON.stringify(result.ok ? [] : result.errors)).toBe(true);\n });\n});\n" },
|
|
106
106
|
{ relPath: "examples/stateful-draft/vitest.config.ts", content: "import { defineConfig } from 'vitest/config';\n\nexport default defineConfig({\n oxc: { jsx: { runtime: 'automatic' } },\n test: { include: ['test/**/*.test.{ts,tsx}'], testTimeout: 30_000, maxWorkers: 2 },\n});\n" },
|
|
107
|
-
{ relPath: "examples/weather/README.md", content: "# Weather Briefing\n\nTwo declarative tools that show the runtime's breadth working together, with **no auth and no API\nkeys**. The `weather_briefing` tool takes a city name and runs a **three-step flow**:\n\nCapability slots: HTTP connector authoring, ordered fulfilment flows, query/response mapping,\n**list-returning connector output** (a connector that returns a live, variable-length array), and\nsandboxed compute, including an explicit least-privilege per-operation response-size bound.\n\nFor a different API with an OpenAPI document, start with `noodle import openapi <file>` in a separate\ndirectory. It preserves supported typed JSON bodies and scalar parameters; unsupported input encodings\nstop import instead of dropping fields. Its offline test establishes the contract, not live behavior; follow the\n[connector guide](https://docs.noodleseed.dev/docs/guides/connectors) before replacing this curated flow.\n\n1. **`geo.search`** → geocode the city to coordinates (Open-Meteo Geocoding API)\n2. **`forecast.current`** → fetch current weather for those coordinates (Open-Meteo Forecast API)\n3. **`brief.summarize`** → derive a human-readable briefing in a **WASM/QuickJS compute sandbox**\n\nThe second tool, `search_places`, shows a connector returning a **live, variable-length list**: it binds\nthe whole Open-Meteo geocoding `results` array with `${response.results}`, then narrows each match to\n`{ id, label }` in a compute connector — the \"search → a list of options the model can pick from\"\npattern. Narrowing lives in compute because a `${...}` response mapping cannot iterate an array and a\ntool's Zod output does not strip fields at runtime.\n\nIt exercises, in one TypeScript-authored app:\n\n- **Server-level branding** with semantic tokens carried through the runtime artifact for any generated\n app surface.\n- **Ordered flow execution** with outputs threaded between steps (`${steps.geo.latitude}` → next step).\n- **Two HTTP connectors on two different hosts**, each with its own egress allowlist.\n- **Query parameters** (`query: [...]`) and a constant query baked into the path (`?current_weather=true`).\n- **Deep response mapping** with the `${...}` language — single-element indexing\n (`${response.results[0].latitude}`, `${response.current_weather.temperature}`) **and** whole-array\n binding (`${response.results}` returns the entire list verbatim).\n- **A list-returning connector + compute narrowing** — `geo.search_list` binds the whole `results`\n array; `places.narrow` reduces each element to `{ id, label }` and normalizes the no-results case\n to `[]`.\n- **A per-operation transport bound** — `search_list` sets\n `limits: { maxResponseBytes: 256 * 1024 }`, tightening this known-small endpoint below the 1 MiB default.\n The authoring ceiling is 6 MiB, but grant only the bytes representative evidence proves this operation\n needs.\n- **Sandboxed compute** (no network/fs/env/clock) turning raw numbers into conditions + advice.\n- **Typed input/output schemas** emitted as JSON Schema 2020-12.\n\n## APIs that require form-urlencoded search bodies\n\nThe live Open-Meteo calls above are GET requests. For APIs whose search endpoint is a POST expecting\n`application/x-www-form-urlencoded`, keep authoring a request object and select the encoding explicitly:\n\n```ts\nsearch_quotes: {\n type: 'read',\n method: 'POST',\n path: '/quotes/search',\n requestEncoding: 'form-urlencoded',\n input: z.object({\n fromAirportId: z.string(),\n categories: z.array(z.string()),\n }),\n request: {\n 'from airport id': '${args.fromAirportId}',\n 'aircraft[categories]': '${args.categories}',\n },\n // output and response mapping omitted\n},\n```\n\nNoodle builds a `URLSearchParams` body: spaces and punctuation in field names are encoded normally, while\neach array or nested object is JSON-stringified into its individual form field. Do not pre-encode the body\nor set `Content-Type` manually; the connector owns both.\n\n## Run it locally\n\nBefore an authorized hosted deployment, inspect this project's inputs with your project-local CLI:\n`noodle deploy preflight --org <org> --app weather --env staging --version 1 --json`.\nThis requires existing hosted access but does not publish or call the weather backend. Its readiness result\ndoes not replace the local and hosted representative-call checks below.\n\nFrom the repo root, with the workspace built (`pnpm build`):\n\n```bash\n: # 1. boot the local loopback dev server\nnode packages/cli/dist/cli.js dev examples/weather/src/server.ts --app weather\n\n: # 2. in another shell, call the printed local endpoint\nURL=http://127.0.0.1:<port>/o/local/weather/dev/mcp\ncurl -s \"$URL\" \\\n -H 'content-type: application/json' \\\n -H 'accept: application/json, text/event-stream' \\\n -H 'mcp-protocol-version: 2025-11-25' \\\n -d '{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\",\"params\":{\"name\":\"weather_briefing\",\"arguments\":{\"city\":\"Paris\"}}}'\n```\n\nExample result (live data, abbreviated):\n\n```json\n{\n \"place\": \"Paris\", \"country\": \"France\",\n \"temperature_c\": 25.1, \"windspeed_kmh\": 8.3,\n \"conditions\": \"overcast\",\n \"headline\": \"Paris, France: 25°C, overcast.\",\n \"advice\": \"Comfortable conditions — no special prep needed.\"\n}\n```\n\nTry other cities (`Reykjavik`, `Singapore`, `Denver`) to see the conditions and advice change.\n\nCall `search_places` to see the **list-returning** tool — one query, many matches:\n\n```bash\ncurl -s \"$URL\" \\\n -H 'content-type: application/json' \\\n -H 'accept: application/json, text/event-stream' \\\n -H 'mcp-protocol-version: 2025-11-25' \\\n -d '{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/call\",\"params\":{\"name\":\"search_places\",\"arguments\":{\"query\":\"Springfield\"}}}'\n```\n\n```json\n{\n \"places\": [\n { \"id\": \"4951788\", \"label\": \"Springfield, Massachusetts, United States\" },\n { \"id\": \"4250542\", \"label\": \"Springfield, Illinois, United States\" },\n { \"id\": \"4508722\", \"label\": \"Springfield, Ohio, United States\" }\n ]\n}\n```\n" },
|
|
107
|
+
{ relPath: "examples/weather/README.md", content: "# Weather Briefing\n\nTwo declarative tools that show the runtime's breadth working together, with **no auth and no API\nkeys**. The `weather_briefing` tool takes a city name and runs a **three-step flow**:\n\nCapability slots: HTTP connector authoring, ordered fulfilment flows, query/response mapping,\n**list-returning connector output** (a connector that returns a live, variable-length array), and\nsandboxed compute, including an explicit least-privilege per-operation response-size bound.\n\nFor a different API with an OpenAPI document, start with `noodle import openapi <file>` in a separate\ndirectory. It preserves supported typed JSON bodies and scalar parameters; unsupported input encodings\nstop import instead of dropping fields. Its offline test establishes the contract, not live behavior; follow the\n[connector guide](https://docs.noodleseed.dev/docs/guides/connectors) before replacing this curated flow.\n\n1. **`geo.search`** → geocode the city to coordinates (Open-Meteo Geocoding API)\n2. **`forecast.current`** → fetch current weather for those coordinates (Open-Meteo Forecast API)\n3. **`brief.summarize`** → derive a human-readable briefing in a **WASM/QuickJS compute sandbox**\n\nThe second tool, `search_places`, shows a connector returning a **live, variable-length list**: it binds\nthe whole Open-Meteo geocoding `results` array with `${response.results}`, then narrows each match to\n`{ id, label }` in a compute connector — the \"search → a list of options the model can pick from\"\npattern. Narrowing lives in compute because a `${...}` response mapping cannot iterate an array and a\ntool's Zod output does not strip fields at runtime.\n\nIt exercises, in one TypeScript-authored app:\n\n- **Server-level branding** with semantic tokens carried through the runtime artifact for any generated\n app surface.\n- **Ordered flow execution** with outputs threaded between steps (`${steps.geo.latitude}` → next step).\n- **Two HTTP connectors on two different hosts**, each with its own egress allowlist.\n- **Query parameters** (`query: [...]`) and a constant query baked into the path (`?current_weather=true`).\n- **Deep response mapping** with the `${...}` language — single-element indexing\n (`${response.results[0].latitude}`, `${response.current_weather.temperature}`) **and** whole-array\n binding (`${response.results}` returns the entire list verbatim).\n- **A list-returning connector + compute narrowing** — `geo.search_list` binds the whole `results`\n array; `places.narrow` reduces each element to `{ id, label }` and normalizes the no-results case\n to `[]`.\n- **A per-operation transport bound** — `search_list` sets\n `limits: { maxResponseBytes: 256 * 1024 }`, tightening this known-small endpoint below the 1 MiB default.\n The authoring ceiling is 6 MiB, but grant only the bytes representative evidence proves this operation\n needs.\n- **Sandboxed compute** (no network/fs/env/clock) turning raw numbers into conditions + advice.\n- **Typed input/output schemas** emitted as JSON Schema 2020-12.\n\n## APIs that require form-urlencoded search bodies\n\nThe live Open-Meteo calls above are GET requests. For APIs whose search endpoint is a POST expecting\n`application/x-www-form-urlencoded`, keep authoring a request object and select the encoding explicitly:\n\n```ts\nsearch_quotes: {\n type: 'read',\n method: 'POST',\n path: '/quotes/search',\n requestEncoding: 'form-urlencoded',\n input: z.object({\n fromAirportId: z.string(),\n categories: z.array(z.string()),\n }),\n request: {\n 'from airport id': '${args.fromAirportId}',\n 'aircraft[categories]': '${args.categories}',\n },\n // output and response mapping omitted\n},\n```\n\nNoodle builds a `URLSearchParams` body: spaces and punctuation in field names are encoded normally, while\neach array or nested object is JSON-stringified into its individual form field. Do not pre-encode the body\nor set `Content-Type` manually; the connector owns both.\n\n## Run it locally\n\nBefore an authorized hosted deployment, inspect this project's inputs with your project-local CLI:\n`noodle deploy preflight --org <org> --app weather --env staging --version 1 --json`.\nThis requires existing hosted access but does not publish or call the weather backend. Its readiness result\ndoes not replace the local and hosted representative-call checks below. Review independent missing bindings,\norigin and target-capability findings together before an authorized repair and recheck.\n\nFrom the repo root, with the workspace built (`pnpm build`):\n\n```bash\n: # 1. boot the local loopback dev server\nnode packages/cli/dist/cli.js dev examples/weather/src/server.ts --app weather\n\n: # 2. in another shell, call the printed local endpoint\nURL=http://127.0.0.1:<port>/o/local/weather/dev/mcp\ncurl -s \"$URL\" \\\n -H 'content-type: application/json' \\\n -H 'accept: application/json, text/event-stream' \\\n -H 'mcp-protocol-version: 2025-11-25' \\\n -d '{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\",\"params\":{\"name\":\"weather_briefing\",\"arguments\":{\"city\":\"Paris\"}}}'\n```\n\nExample result (live data, abbreviated):\n\n```json\n{\n \"place\": \"Paris\", \"country\": \"France\",\n \"temperature_c\": 25.1, \"windspeed_kmh\": 8.3,\n \"conditions\": \"overcast\",\n \"headline\": \"Paris, France: 25°C, overcast.\",\n \"advice\": \"Comfortable conditions — no special prep needed.\"\n}\n```\n\nTry other cities (`Reykjavik`, `Singapore`, `Denver`) to see the conditions and advice change.\n\nCall `search_places` to see the **list-returning** tool — one query, many matches:\n\n```bash\ncurl -s \"$URL\" \\\n -H 'content-type: application/json' \\\n -H 'accept: application/json, text/event-stream' \\\n -H 'mcp-protocol-version: 2025-11-25' \\\n -d '{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/call\",\"params\":{\"name\":\"search_places\",\"arguments\":{\"query\":\"Springfield\"}}}'\n```\n\n```json\n{\n \"places\": [\n { \"id\": \"4951788\", \"label\": \"Springfield, Massachusetts, United States\" },\n { \"id\": \"4250542\", \"label\": \"Springfield, Illinois, United States\" },\n { \"id\": \"4508722\", \"label\": \"Springfield, Ohio, United States\" }\n ]\n}\n```\n" },
|
|
108
108
|
{ relPath: "examples/weather/noodle.json", content: "{\n \"entrypoint\": \"src/server.ts\",\n \"name\": \"weather\"\n}\n" },
|
|
109
109
|
{ relPath: "examples/weather/package.json", content: "{\n \"name\": \"weather\",\n \"version\": \"0.1.0\",\n \"private\": true,\n \"type\": \"module\",\n \"scripts\": {\n \"test\": \"vitest run\",\n \"validate\": \"noodle validate\",\n \"dev\": \"noodle dev\",\n \"deploy\": \"noodle deploy\"\n },\n \"devDependencies\": {\n \"@noodleseed/one\": \"latest\",\n \"vitest\": \"latest\"\n }\n}\n" },
|
|
110
110
|
{ relPath: "examples/weather/src/server.ts", content: "import { connector, server, tool, z } from '@noodleseed/one';\n\n// The same Weather Briefing server, authored in TypeScript with the Noodle authoring SDK.\n//\n// The SDK owns the *manifest*: the tool, its Zod-typed input/output schemas, and the flow — which you\n// write as ordinary code in `fulfil` and the SDK records symbolically into ordered steps.\n//\n// HTTP and compute connectors are authored here too, so the public developer entrypoint is one\n// self-contained server.ts. The SDK still compiles this to internal manifest/catalog data for the runtime.\n\nconst geocoding = connector('open_meteo_geocoding')\n .version('1.0.0')\n .http({\n baseUrl: 'https://geocoding-api.open-meteo.com',\n allowedOrigins: ['https://geocoding-api.open-meteo.com'],\n operations: {\n search: {\n type: 'read',\n method: 'GET',\n path: '/v1/search',\n query: ['name'],\n input: z.object({ name: z.string() }),\n output: z.object({\n latitude: z.number(),\n longitude: z.number(),\n place: z.string().optional(),\n country: z.string().optional(),\n }),\n response: {\n latitude: '${response.results[0].latitude}',\n longitude: '${response.results[0].longitude}',\n place: '${response.results[0].name}',\n country: '${response.results[0].country}',\n },\n },\n // A LIST-returning read. `${response.results}` binds the WHOLE array verbatim — a\n // variable-length list of place objects — with no pagination (Open-Meteo returns every match in\n // one page). Contrast the `search` op above, which indexes a single element (`results[0]`). To\n // reduce each element to a few fields, narrow it in the `geo_places` compute connector below: a\n // response mapping cannot iterate an array, and a tool's Zod output does not strip fields at\n // runtime.\n search_list: {\n type: 'read',\n method: 'GET',\n path: '/v1/search',\n query: ['name', 'count'],\n // This endpoint is intentionally small; tighten its allowance below the 1 MiB default.\n limits: { maxResponseBytes: 256 * 1024 },\n input: z.object({ name: z.string(), count: z.number().optional() }),\n output: z.object({ results: z.array(z.unknown()).optional() }),\n response: {\n results: '${response.results}',\n },\n },\n },\n });\n\nconst forecast = connector('open_meteo_forecast')\n .version('1.0.0')\n .http({\n baseUrl: 'https://api.open-meteo.com',\n allowedOrigins: ['https://api.open-meteo.com'],\n operations: {\n current: {\n type: 'read',\n method: 'GET',\n path: '/v1/forecast?current_weather=true',\n query: ['latitude', 'longitude'],\n input: z.object({ latitude: z.number(), longitude: z.number() }),\n output: z.object({\n temperature: z.number().optional(),\n windspeed: z.number().optional(),\n weathercode: z.number().optional(),\n }),\n response: {\n temperature: '${response.current_weather.temperature}',\n windspeed: '${response.current_weather.windspeed}',\n weathercode: '${response.current_weather.weathercode}',\n },\n },\n },\n });\n\nconst brief = connector('weather_brief')\n .version('1.0.0')\n .compute('summarize', {\n type: 'read',\n input: z.object({\n place: z.string(),\n country: z.string().optional(),\n temperature: z.number(),\n windspeed: z.number(),\n weathercode: z.number(),\n }),\n output: z.object({\n conditions: z.string(),\n headline: z.string(),\n advice: z.string(),\n }),\n // A real function — type-checked here, serialized to source and run in the sandbox. It must be\n // self-contained: no imports, no closure over outer variables, synchronous.\n run: (input) => {\n const codes: Record<number, string> = {\n 0: 'clear sky',\n 1: 'mainly clear',\n 2: 'partly cloudy',\n 3: 'overcast',\n 45: 'fog',\n 48: 'depositing rime fog',\n 51: 'light drizzle',\n 53: 'moderate drizzle',\n 55: 'dense drizzle',\n 61: 'slight rain',\n 63: 'moderate rain',\n 65: 'heavy rain',\n 71: 'slight snow',\n 73: 'moderate snow',\n 75: 'heavy snow',\n 77: 'snow grains',\n 80: 'slight rain showers',\n 81: 'moderate rain showers',\n 82: 'violent rain showers',\n 85: 'slight snow showers',\n 86: 'heavy snow showers',\n 95: 'thunderstorm',\n 96: 'thunderstorm with hail',\n 99: 'thunderstorm with heavy hail',\n };\n const code = Number(input.weathercode);\n const conditions = codes[code] || 'unknown conditions';\n const temp = Math.round(Number(input.temperature));\n const wind = Math.round(Number(input.windspeed));\n const where = input.country ? `${input.place}, ${input.country}` : input.place;\n const headline = `${where}: ${temp}°C, ${conditions}.`;\n const tips: string[] = [];\n if (temp <= 0) tips.push(\"bundle up, it's freezing\");\n else if (temp <= 10) tips.push('wear a warm coat');\n else if (temp >= 28) tips.push(\"stay hydrated, it's hot\");\n if (code >= 95) tips.push('thunderstorms expected — seek shelter');\n else if (code >= 71 && code <= 86 && code !== 80 && code !== 81 && code !== 82)\n tips.push('snow — dress warm and tread carefully');\n else if (code >= 51 && code <= 82) tips.push('bring an umbrella');\n if (wind >= 30) tips.push('expect strong winds');\n const advice = tips.length\n ? `${tips.join('; ')}.`\n : 'Comfortable conditions — no special prep needed.';\n return { conditions, headline, advice };\n },\n });\n\n// Narrowing a live list to `{ id, label }` summaries is the ONE reshape a response mapping cannot do\n// (the `${...}` language has no per-item iteration) and a tool's Zod output does not enforce at runtime\n// — so it happens here, in a sandboxed compute connector (a connector is HTTP or compute, not both).\n// This also normalizes the no-results case (Open-Meteo omits `results` when nothing matches) to `[]`.\nconst placeNarrow = connector('geo_places')\n .version('1.0.0')\n .compute('narrow', {\n type: 'read',\n input: z.object({ results: z.unknown().optional() }),\n output: z.object({ places: z.array(z.unknown()) }),\n // Self-contained: no imports, no closure over outer variables, synchronous.\n run: (input) => {\n const raw = input.results;\n const list = Array.isArray(raw) ? raw : [];\n const places = list.map((entry) => {\n const parts = [entry.name, entry.admin1, entry.country].filter(\n (part) => typeof part === 'string' && part.length > 0,\n );\n const id =\n entry.id !== undefined && entry.id !== null\n ? String(entry.id)\n : `${entry.latitude},${entry.longitude}`;\n return { id, label: parts.join(', ') };\n });\n return { places };\n },\n });\n\nexport default server(\n 'weather_briefing',\n {\n title: 'Weather Briefing',\n version: '1.0.0',\n use: { geo: geocoding, forecast, brief, places: placeNarrow },\n branding: {\n name: 'Weather Briefing',\n accent: '#0284C7',\n radius: 'md',\n density: 'comfortable',\n },\n },\n [\n tool('weather_briefing', {\n title: 'Weather briefing',\n description:\n 'Look up a city, fetch its current weather, and return a human-readable briefing. Runs a ' +\n 'three-step flow: geocode the city, fetch the forecast, then derive the briefing in a sandboxed compute step.',\n input: z.object({\n city: z.string(),\n }),\n output: z.object({\n place: z.string(),\n country: z.string(),\n temperature_c: z.number(),\n windspeed_kmh: z.number(),\n conditions: z.string(),\n headline: z.string(),\n advice: z.string(),\n }),\n fulfil: ({ input, connectors }) => {\n const located = connectors.geo.search({ name: input.city });\n const weather = connectors.forecast.current({\n latitude: located.latitude,\n longitude: located.longitude,\n });\n const briefing = connectors.brief.summarize({\n place: located.place,\n country: located.country,\n temperature: weather.temperature,\n windspeed: weather.windspeed,\n weathercode: weather.weathercode,\n });\n return {\n place: located.place,\n country: located.country,\n temperature_c: weather.temperature,\n windspeed_kmh: weather.windspeed,\n conditions: briefing.conditions,\n headline: briefing.headline,\n advice: briefing.advice,\n };\n },\n }),\n // A connector that returns a live, variable-length LIST: search a place name, get back the\n // matching locations as `{ id, label }` options the model can resolve against. The HTTP op binds\n // the whole array; the compute connector narrows each element to the two fields the model speaks\n // from. Append new tools AFTER existing ones so `tools[0]` stays stable for host harnesses.\n tool('search_places', {\n title: 'Search places',\n description:\n 'Search a place name and return the matching locations as a list of { id, label } options.',\n // Bound the list at the source: `limit` is capped in the schema and passed through to the\n // upstream `count` parameter, so the model can never pull an unbounded page into its context.\n // `noodle check` reports an unbounded array output as `tool_design_output_bounds`.\n input: z.object({\n query: z.string(),\n limit: z.number().int().min(1).max(10).default(5),\n }),\n output: z.object({\n places: z.array(z.object({ id: z.string(), label: z.string() })),\n }),\n fulfil: ({ input, connectors }) => {\n const found = connectors.geo.search_list({ name: input.query, count: input.limit });\n const narrowed = connectors.places.narrow({ results: found.results });\n return { places: narrowed.places };\n },\n }),\n ],\n);\n" },
|
|
@@ -229,7 +229,7 @@ export function renderEmbeddedAssistantReference() {
|
|
|
229
229
|
'',
|
|
230
230
|
'Set `continuity: { enabled: true }` on a public or mixed access surface to let an anonymous visitor keep the conversation they can see when they navigate to another page of the same site. Off unless set, and refused on an authenticated surface, which reattaches through a backend-verified sign-in instead. It restores the visible text on a fresh session and never the old one: no tool authority, no share of a spent turn budget, and no pending confirmation carried across, so an unanswered confirmation stays unanswered. `windowSeconds` defaults to 300 with a 600 ceiling, `maxRestores` to 3 with a ceiling of 10, and 0 for either disables continuity outright; an operator may lower what you declare and can never raise it. The handle lives in `sessionStorage` so it dies with the tab, is single-use, and is valid only for the embed, origin, and visitor it was issued to. Declare it when a marketing site spreads one conversation across several pages; leave it off when anonymous conversation text should not survive a navigation at all.',
|
|
231
231
|
'',
|
|
232
|
-
'Give every business action a portable `tool(..., { title: "Complete task", description: "This will mark the task complete for everyone.", input: z.object({ task: z.string().meta({ title: "Task" }) }) })` title. The standard confirmation uses the tool title/description plus schema field `title`, `description`, and `format`; it shows Confirm and Don\'t proceed and keeps technical action details
|
|
232
|
+
'Give every business action a portable `tool(..., { title: "Complete task", description: "This will mark the task complete for everyone.", input: z.object({ task: z.string().meta({ title: "Task" }) }) })` title. The standard confirmation uses the tool title/description plus schema field `title`, `description`, and `format`; it shows Confirm and Don\'t proceed and keeps technical action details out of the default review. `behavior.showConfirmationDetails` defaults to `false`; set it to `true` only when the audience needs the collapsed Additional details disclosure. The complete business review and decisions remain, `confirm: true` still suspends until acceptance, and headless/BYO `data-confirmation` stays unchanged. Do not put JSON or implementation names in business-facing copy.',
|
|
233
233
|
'',
|
|
234
234
|
'## Configure and deploy',
|
|
235
235
|
'',
|
|
@@ -62,7 +62,7 @@ export function renderVerifyAndRecoverReference() {
|
|
|
62
62
|
'- Real API: distinguish authentication, reachability, legitimate empty results, and broken response mappings before changing code.',
|
|
63
63
|
'- App: repair the cited contract or state in `noodle check --json`, then confirm it in devtools before attempting a host.',
|
|
64
64
|
'- Host/deployment/production: confirm revision, target, identity, and configuration independently; do not infer one from another.',
|
|
65
|
-
'- Authored deploy readiness: with existing access and the intended target, inspect `noodle deploy preflight --json` through the installed execution transport (`noodle-readiness.preflight_build` for plugin users). It never configures or publishes.
|
|
65
|
+
'- Authored deploy readiness: with existing access and the intended target, inspect `noodle deploy preflight --json` through the installed execution transport (`noodle-readiness.preflight_build` for plugin users). It never configures or publishes. Review independent config, origin, capability, auth and rendering blockers together; denied or invalid inputs can prevent dependent checks. Proposed configuration commands require separate authorization. Readiness is not a backend call or hosted journey.',
|
|
66
66
|
'- Repeated external failure: preserve passing evidence and report the sanitized failure, required authority or external state, owner, and exact next action.',
|
|
67
67
|
'',
|
|
68
68
|
'## Stop conditions',
|
|
@@ -1,11 +1,16 @@
|
|
|
1
1
|
import type { CompileError, RuntimeArtifact } from '@noodle-borg/compiler';
|
|
2
|
+
interface ManagedOriginError extends CompileError {
|
|
3
|
+
readonly variableName: string;
|
|
4
|
+
readonly reason: 'missing' | 'invalid';
|
|
5
|
+
}
|
|
2
6
|
export type ManagedOriginResolutionResult = {
|
|
3
7
|
readonly ok: true;
|
|
4
8
|
readonly artifact: RuntimeArtifact;
|
|
5
9
|
} | {
|
|
6
10
|
readonly ok: false;
|
|
7
|
-
readonly errors: readonly
|
|
11
|
+
readonly errors: readonly ManagedOriginError[];
|
|
8
12
|
};
|
|
9
13
|
/** Bind operator-owned exact origins into every runtime authority and host projection. */
|
|
10
14
|
export declare function resolveManagedOrigins(artifact: RuntimeArtifact, variables: Readonly<Record<string, string>>): ManagedOriginResolutionResult;
|
|
15
|
+
export {};
|
|
11
16
|
//# sourceMappingURL=managed-origins.d.ts.map
|
|
@@ -53,6 +53,8 @@ function resolveManagedOrigin(value, variables, path, allowLoopback, replacement
|
|
|
53
53
|
if (resolved === undefined) {
|
|
54
54
|
errors.push({
|
|
55
55
|
code: 'invalid_shape',
|
|
56
|
+
variableName: match[1],
|
|
57
|
+
reason: 'missing',
|
|
56
58
|
path,
|
|
57
59
|
message: `managed origin variable "${match[1]}" is not configured`,
|
|
58
60
|
});
|
|
@@ -61,6 +63,8 @@ function resolveManagedOrigin(value, variables, path, allowLoopback, replacement
|
|
|
61
63
|
if (!isCanonicalOrigin(resolved, allowLoopback)) {
|
|
62
64
|
errors.push({
|
|
63
65
|
code: 'invalid_shape',
|
|
66
|
+
variableName: match[1],
|
|
67
|
+
reason: 'invalid',
|
|
64
68
|
path,
|
|
65
69
|
message: allowLoopback
|
|
66
70
|
? `managed origin variable "${match[1]}" must resolve to a canonical bare HTTPS origin (loopback HTTP is allowed for development)`
|
|
@@ -45,14 +45,10 @@ export async function compileRegistryTarget(context, input) {
|
|
|
45
45
|
: { localDevtoolsCustomerIdentity: true }),
|
|
46
46
|
})
|
|
47
47
|
: [];
|
|
48
|
-
if (identityErrors.length > 0)
|
|
49
|
-
return { ok: false, errors: identityErrors };
|
|
50
48
|
// A delegated-auth tool on a pure public surface inevitably fails; reject it while the author can fix it.
|
|
51
49
|
const projectionErrors = compiled.ok
|
|
52
50
|
? publicSurfaceDelegatedAuthErrors(compiled.artifact, secretBindings)
|
|
53
51
|
: [];
|
|
54
|
-
if (projectionErrors.length > 0)
|
|
55
|
-
return { ok: false, errors: projectionErrors };
|
|
56
52
|
const [resolvedSecrets, resolvedVariables] = await Promise.all([
|
|
57
53
|
context.configStore.resolveConfigValues('secret', scope),
|
|
58
54
|
context.configStore.resolveConfigValues('variable', scope),
|
|
@@ -65,13 +61,20 @@ export async function compileRegistryTarget(context, input) {
|
|
|
65
61
|
return { ok: false, errors: [...connectorConfigErrors, ...compiled.errors] };
|
|
66
62
|
}
|
|
67
63
|
const missingServerConfig = missingServerConfigErrors(compiled.artifact, resolvedSecrets, resolvedVariables);
|
|
68
|
-
const
|
|
69
|
-
|
|
70
|
-
|
|
64
|
+
const errors = [
|
|
65
|
+
...identityErrors,
|
|
66
|
+
...projectionErrors,
|
|
67
|
+
...connectorConfigErrors,
|
|
68
|
+
...missingServerConfig,
|
|
69
|
+
];
|
|
71
70
|
const originResolution = resolveManagedOrigins(compiled.artifact, resolvedVariables);
|
|
72
|
-
if (!originResolution.ok)
|
|
73
|
-
|
|
74
|
-
|
|
71
|
+
if (!originResolution.ok) {
|
|
72
|
+
const missingVariables = new Set(errors.filter((error) => error.code === 'missing_variable').map((error) => error.path));
|
|
73
|
+
errors.push(...originResolution.errors
|
|
74
|
+
// An unset binding is already actionable config, not an independent invalid-origin fault.
|
|
75
|
+
.filter((error) => error.reason !== 'missing' || !missingVariables.has(`variables.${error.variableName}`))
|
|
76
|
+
.map(({ code, path, message }) => ({ code, path, message })));
|
|
77
|
+
}
|
|
75
78
|
let appPackageSnapshot;
|
|
76
79
|
if (input.renderAppPackage && compiled.appPackage !== undefined) {
|
|
77
80
|
try {
|
|
@@ -79,11 +82,17 @@ export async function compileRegistryTarget(context, input) {
|
|
|
79
82
|
}
|
|
80
83
|
catch (error) {
|
|
81
84
|
if (error instanceof AppPackageSnapshotError) {
|
|
82
|
-
|
|
85
|
+
errors.push(error.deployError);
|
|
86
|
+
}
|
|
87
|
+
else {
|
|
88
|
+
throw error;
|
|
83
89
|
}
|
|
84
|
-
throw error;
|
|
85
90
|
}
|
|
86
91
|
}
|
|
92
|
+
if (errors.length > 0 || !originResolution.ok) {
|
|
93
|
+
return { ok: false, errors, compiledArtifact: compiled.artifact };
|
|
94
|
+
}
|
|
95
|
+
const artifact = originResolution.artifact;
|
|
87
96
|
const localAuthority = context.delegatedExchange === undefined &&
|
|
88
97
|
secretBindings.some((binding) => binding.authKind === 'delegatedTokenExchange')
|
|
89
98
|
? await context.localDevtoolsDelegatedExchange?.resolve()
|
|
@@ -10,20 +10,24 @@ export async function preflightRegistryDeploy(input) {
|
|
|
10
10
|
return deployerRequiredError(accessMode);
|
|
11
11
|
}
|
|
12
12
|
const built = await input.compile();
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
13
|
+
const errors = built.ok ? [] : [...built.errors];
|
|
14
|
+
const artifact = built.ok ? built.served.artifact : built.compiledArtifact;
|
|
15
|
+
if (artifact !== undefined) {
|
|
16
|
+
errors.push(...missingCapabilityErrors(artifact.requirements?.capabilities ?? [], input.serviceCapabilities));
|
|
17
|
+
if (accessMode === 'customers' && artifact.server.auth === undefined) {
|
|
18
|
+
errors.push(...serverAuthRequiredError().errors);
|
|
19
|
+
}
|
|
20
20
|
}
|
|
21
21
|
if (orgMembershipSources !== undefined) {
|
|
22
22
|
if (orgMembershipSources.length === 0)
|
|
23
|
-
|
|
24
|
-
if (accessMode !== 'org-members')
|
|
25
|
-
|
|
23
|
+
errors.push(...emptyMembershipSourcesError().errors);
|
|
24
|
+
if (accessMode !== 'org-members') {
|
|
25
|
+
errors.push(...membershipSourcesRequireOrgMembersError().errors);
|
|
26
|
+
}
|
|
26
27
|
}
|
|
28
|
+
// Only diagnostics cross the registry/API boundary; failed compilation cannot publish metadata.
|
|
29
|
+
if (!built.ok || errors.length > 0)
|
|
30
|
+
return { ok: false, errors };
|
|
27
31
|
return {
|
|
28
32
|
ok: true,
|
|
29
33
|
serverName: built.served.artifact.server.name,
|
|
@@ -100,17 +100,19 @@ export async function handleDeployPreflight(req, res, registry, options, maxBody
|
|
|
100
100
|
const ownerSelection = await authorizeDeploymentOwnerSelection(res, controlPlane, tenant.org, accessMode, identity, parsed.ownerSubject);
|
|
101
101
|
if (!ownerSelection.ok)
|
|
102
102
|
return;
|
|
103
|
+
const requestErrors = [];
|
|
103
104
|
if (accessMode === 'public' && manifestUsesUserRoot(parsed.manifest)) {
|
|
104
|
-
|
|
105
|
+
requestErrors.push({
|
|
105
106
|
code: 'public_user_context_conflict',
|
|
106
|
-
|
|
107
|
+
path: 'accessMode',
|
|
108
|
+
message: 'public access mode cannot reference ${user}; use mixed for optional identity',
|
|
107
109
|
});
|
|
108
110
|
}
|
|
109
|
-
const
|
|
110
|
-
|
|
111
|
-
return sendJson(res, 400, {
|
|
111
|
+
for (const cspFault of cspFaultsInManifest(parsed.manifest)) {
|
|
112
|
+
requestErrors.push({
|
|
112
113
|
code: 'invalid_widget_csp',
|
|
113
|
-
|
|
114
|
+
path: `widgets.${cspFault.widgetIndex}.csp.${cspFault.list}.${cspFault.index}`,
|
|
115
|
+
message: `widget "${cspFault.widget}" CSP ${cspFault.list} origin "${cspFault.value}" is not an ` +
|
|
114
116
|
`absolute https:// origin and would be dropped by the host renderer` +
|
|
115
117
|
(cspFault.suggestion !== undefined ? `; use "${cspFault.suggestion}"` : ''),
|
|
116
118
|
});
|
|
@@ -149,7 +151,7 @@ export async function handleDeployPreflight(req, res, registry, options, maxBody
|
|
|
149
151
|
})
|
|
150
152
|
.finally(() => clearTimeout(timer));
|
|
151
153
|
const [application, environment, preflight] = checked;
|
|
152
|
-
const errors = preflight.ok ? [] : preflight.errors;
|
|
154
|
+
const errors = [...requestErrors, ...(preflight.ok ? [] : preflight.errors)];
|
|
153
155
|
const missingSecrets = configNames(errors, 'missing_secret', 'secrets.');
|
|
154
156
|
const missingVariables = configNames(errors, 'missing_variable', 'variables.');
|
|
155
157
|
const appState = application === undefined ? 'will-create' : 'existing';
|
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
"dependencies": {
|
|
40
40
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
41
41
|
"@noodle-borg/admission-limits": "0.0.0",
|
|
42
|
-
"@noodle-borg/agent-kit": "0.
|
|
42
|
+
"@noodle-borg/agent-kit": "0.99.0",
|
|
43
43
|
"@noodle-borg/app-package": "0.0.0",
|
|
44
44
|
"@noodle-borg/assistant-gateway": "0.0.0",
|
|
45
45
|
"@noodle-borg/auth": "0.0.0",
|
|
@@ -159,19 +159,21 @@ With UI overrides omitted, the complete managed baseline remains: a bottom-cente
|
|
|
159
159
|
morphs into a prompt input before opening; a 970px outer desktop shell with 20px side padding, 85vh height,
|
|
160
160
|
1025px maximum height, and a 24px panel using the built-in `#F8F8F8` light and `#0C0A09` dark surfaces;
|
|
161
161
|
bottom prompt chips and pill composer; plain assistant messages and 85%-wide user bubbles; a Noodle Seed
|
|
162
|
-
attribution row; and safe-area-aware mobile fullscreen.
|
|
162
|
+
attribution row; and safe-area-aware mobile fullscreen. The fullscreen mobile layout is a real modal: it
|
|
163
|
+
marks the page behind it inert, traps focus, closes on Escape, and returns focus to the element that opened
|
|
164
|
+
it. Wider floating panels and inline layouts remain nonmodal.
|
|
163
165
|
The generic prompt chips ship as defaults, while an authored list—including `[]`—replaces them. Set
|
|
164
166
|
`presentation.launcher.style` to `bubble` for a direct-open 44px launcher. The baseline does not include the
|
|
165
167
|
“Available on ChatGPT” promotion. Partial configuration objects merge with the remaining defaults;
|
|
166
168
|
`presentation.panel.surface: "glass"` remains available when a translucent panel is intentional.
|
|
167
169
|
|
|
168
|
-
`behavior.showConfirmationDetails` defaults to `
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
the managed Web Component's presentation: `confirm: true` still suspends
|
|
172
|
-
only after acceptance, and decline/cancel still stop. Public `embedId`
|
|
173
|
-
React mounts both honor the deployed value without a client prop.
|
|
174
|
-
`data-confirmation` part and choose their own presentation.
|
|
170
|
+
`behavior.showConfirmationDetails` defaults to `false`, so the built-in confirmation card presents the tool
|
|
171
|
+
title, description, complete schema-projected business fields, Confirm, and Don't proceed without exposing
|
|
172
|
+
connector mechanics. Set it to `true` only when the audience needs the collapsed Additional details
|
|
173
|
+
disclosure. The option changes only the managed Web Component's presentation: `confirm: true` still suspends
|
|
174
|
+
execution, the connector still runs only after acceptance, and decline/cancel still stop. Public `embedId`
|
|
175
|
+
and authenticated `sessionEndpoint` React mounts both honor the deployed value without a client prop.
|
|
176
|
+
Headless/BYO renderers keep the unchanged `data-confirmation` part and choose their own presentation.
|
|
175
177
|
|
|
176
178
|
### Configure and deploy
|
|
177
179
|
|
|
@@ -439,6 +441,11 @@ viewport. If fullscreen is an intentional part of the customer-owned experience,
|
|
|
439
441
|
because a widget requests it. After an accepted fullscreen request, the host displays an accessible exit
|
|
440
442
|
control in the top-right corner. It returns the same mounted App to inline mode without resetting its state.
|
|
441
443
|
|
|
444
|
+
A conforming inline App reports its content size through the Apps bridge. The host grows the iframe with
|
|
445
|
+
that content, up to a 16,384px safety bound, so the conversation transcript remains the only vertical scroll
|
|
446
|
+
area and stays anchored to the latest reply while the App grows. Apps that need more space should compact
|
|
447
|
+
their inline view or use an explicitly allowed fullscreen presentation.
|
|
448
|
+
|
|
442
449
|
Vue, Angular, and plain DOM renderers use the same host without installing React. Import its dedicated entry
|
|
443
450
|
once, then assign the complex values as element properties. In Vue, the explicit `.prop` modifier makes that
|
|
444
451
|
boundary unambiguous:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@noodleseed/one",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.161.1",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Noodle CLI by Noodle Seed — author, run, and deploy declarative MCP servers. Embedding the assistant in your own web app is @noodleseed/assistant.",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -235,7 +235,7 @@
|
|
|
235
235
|
"@modelcontextprotocol/client": "2.0.0",
|
|
236
236
|
"@modelcontextprotocol/server": "2.0.0",
|
|
237
237
|
"@noodle-borg/admission-limits": "0.0.0",
|
|
238
|
-
"@noodle-borg/agent-kit": "0.
|
|
238
|
+
"@noodle-borg/agent-kit": "0.99.0",
|
|
239
239
|
"@noodle-borg/app-audit": "0.0.0",
|
|
240
240
|
"@noodle-borg/app-package": "0.0.0",
|
|
241
241
|
"@noodle-borg/assistant-gateway": "0.0.0",
|