@noodleseed/agent-kit 0.21.0 → 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -7,6 +7,7 @@
7
7
  - Repair loop
8
8
  - Connectors
9
9
  - HTTP connector example (full server)
10
+ - Delegated downstream auth (call your API as the signed-in user)
10
11
  - Design tools for the model
11
12
  - Compute connector example
12
13
  - Tests
@@ -37,7 +38,7 @@ Declare connectors as data, not imperative code:
37
38
 
38
39
  Tools record connector calls into a flow; recording is not execution. Do not branch on runtime outputs with native `if` — use declarative `when(...)` conditions.
39
40
 
40
- HTTP connector auth variants: `bearer` (`{ kind: "bearer", secret: secret("API_TOKEN") }`), `apiKey` (`{ kind: "apiKey", header: "X-API-Key", secret: secret("API_KEY") }`), `clientCredentials`, `delegatedOAuth`, and `delegatedSessionCookie`. Use managed `secret(...)` / `variable(...)` refs for all values that differ by org/app/env.
41
+ HTTP connector auth variants: `bearer` (`{ kind: "bearer", secret: secret("API_TOKEN") }`), `apiKey` (`{ kind: "apiKey", header: "X-API-Key", secret: secret("API_KEY") }`), `clientCredentials`, `delegatedOAuth`, `delegatedSessionCookie`, and `delegatedTokenExchange` (per-user calls to your own API — see "Delegated downstream auth" below). Use managed `secret(...)` / `variable(...)` refs for all values that differ by org/app/env.
41
42
 
42
43
  ## HTTP connector example (full server)
43
44
 
@@ -96,7 +97,76 @@ export default server('support', { title: 'Support', version: '1.0.0', use: { cr
96
97
 
97
98
  Naming: connector operation names and tool names are lowercase-with-underscores. Map with `${args.field}` for tool/operation inputs and `${response.path}` for the response — the parsed JSON body is bound directly to `${response}`, so there is **no `.body` envelope**; use bracket syntax for array indices (`${response.data[0].id}`) — a dotted numeric index like `.0.` is invalid. Declare URL query parameters with the operation-level `query: ["arg"]` array, **not** inside `request` (which builds only the JSON body). `allowedOrigins` must be literal origin URLs (the SSRF allowlist); `baseUrl` may be a `variable(...)` that differs by env.
98
99
 
99
- More: `auth.kind` is `bearer` | `apiKey` (needs `header`) | `clientCredentials` | `delegatedOAuth` | `delegatedSessionCookie`. For client credentials use `{ kind: "clientCredentials", tokenUrl, clientId, clientSecret, scopes? }` (RFC-6749 grant); for a non-standard partner token endpoint add `profile: "custom"` with a `custom: { requestFormat, clientIdField, clientSecretField, tokenResponsePath, expirySource }` descriptor. Do not put credential headers in operation `headers`; use connector `auth`. For per-customer downstream calls use `delegatedOAuth` or `delegatedSessionCookie` with a matching `customerAuth` server option. Use `.compute(name, { input, output, run })` for a sandboxed transform; `provides:` (instead of `use:`) exposes a connector only to compute `callOperation`; and `noodle import openapi <file>` generates a connector from an OpenAPI spec.
100
+ More: `auth.kind` is `bearer` | `apiKey` (needs `header`) | `clientCredentials` | `delegatedOAuth` | `delegatedSessionCookie` | `delegatedTokenExchange`. For client credentials use `{ kind: "clientCredentials", tokenUrl, clientId, clientSecret, scopes? }` (RFC-6749 grant); for a non-standard partner token endpoint add `profile: "custom"` with a `custom: { requestFormat, clientIdField, clientSecretField, tokenResponsePath, expirySource }` descriptor. Do not put credential headers in operation `headers`; use connector `auth`. Use `.compute(name, { input, output, run })` for a sandboxed transform; `provides:` (instead of `use:`) exposes a connector only to compute `callOperation`; and `noodle import openapi <file>` generates a connector from an OpenAPI spec.
101
+
102
+ ## Delegated downstream auth (call your API as the signed-in user)
103
+
104
+ Use delegated connector auth when the downstream API must enforce its own per-user authorization — a shared service credential plus a forwarded user id would bypass it. Three shapes exist; pick by who owns the downstream:
105
+
106
+ - **`delegatedTokenExchange`** — your own API. The platform signs a short-lived, verifiable assertion of the signed-in user and exchanges it at a token endpoint you implement (RFC 8693). Works with `customerAuth.bridge(...)` identities and embedded-assistant sessions; no per-user OAuth enrollment.
107
+ - **`delegatedOAuth` with `provider: "firebase" | "microsoft"`** — Noodle-managed bridge providers using stored per-user refresh tokens. Requires the matching `customerAuth` bridge; any other provider string is the compile error `unsupported_delegated_provider`.
108
+ - **`delegatedSessionCookie`** — Firebase-managed session-cookie apps only; not a generic mechanism.
109
+
110
+ ### The connector (your `server.ts`)
111
+
112
+ ```ts
113
+ auth: {
114
+ kind: 'delegatedTokenExchange',
115
+ tokenUrl: 'https://app.example.com/api/assistant/oauth/token', // origin must be in allowedOrigins
116
+ clientId: variable('EXAMPLE_DELEG_CLIENT_ID'),
117
+ clientSecret: secret('EXAMPLE_DELEG_CLIENT_SECRET'),
118
+ scopes: ['time_off'], // optional
119
+ audience: 'example-api', // optional; assertion + request audience, defaults to tokenUrl
120
+ authMethod: 'client_secret_basic', // default; client_secret_post supported
121
+ }
122
+ ```
123
+
124
+ Inside tools, `${user.id}` / `${user.email}` / `${user.name}` / `${user.claims.*}` stay available as verified context; the delegated credential is what makes the *downstream call itself* run as that user.
125
+
126
+ ### The exchange request your endpoint receives
127
+
128
+ The broker POSTs `application/x-www-form-urlencoded` to `tokenUrl` with `Authorization: Basic base64(clientId:clientSecret)` (or `client_id`/`client_secret` form fields for `client_secret_post`):
129
+
130
+ ```
131
+ grant_type=urn:ietf:params:oauth:grant-type:token-exchange
132
+ subject_token=<RS256 JWT signed by the platform>
133
+ subject_token_type=urn:ietf:params:oauth:token-type:jwt
134
+ scope=time_off (space-joined, when configured)
135
+ audience=example-api (when configured)
136
+ ```
137
+
138
+ The `subject_token` claims: `iss` (platform issuer; JWKS at `{iss}/.well-known/jwks.json`), `sub` (verified user id), `aud` (your configured audience or the tokenUrl), `email`, `name`, `claims` (declared session claims), `tenant` (`org/app/env`), `deployment`, `iat`, `exp` (about 120 s), `jti`. Respond with `{ "access_token": "...", "token_type": "Bearer", "expires_in": 900 }`; the broker caches per user + connector + scopes until `expires_in` minus 300 s and presents the token downstream as `Authorization: Bearer`.
139
+
140
+ ### The downstream token endpoint (your backend)
141
+
142
+ ```ts
143
+ // POST /api/assistant/oauth/token — Node example with jose.
144
+ import { createRemoteJWKSet, jwtVerify } from 'jose';
145
+
146
+ const PLATFORM_ISSUER = process.env.NOODLE_PLATFORM_ISSUER!; // e.g. https://cloud.noodleseed.dev
147
+ const jwks = createRemoteJWKSet(new URL(`${PLATFORM_ISSUER}/.well-known/jwks.json`));
148
+
149
+ export async function tokenEndpoint(req: Request): Promise<Response> {
150
+ // 1. Authenticate the broker client credential (client_secret_basic).
151
+ const basic = req.headers.get('authorization') ?? '';
152
+ const [clientId, clientSecret] = atob(basic.replace(/^Basic /, '')).split(':');
153
+ if (!isValidClient(clientId, clientSecret)) return new Response(null, { status: 401 });
154
+ // 2. Verify the platform-signed user assertion (never trust a plaintext user id).
155
+ const form = new URLSearchParams(await req.text());
156
+ const { payload } = await jwtVerify(form.get('subject_token') ?? '', jwks, {
157
+ issuer: PLATFORM_ISSUER,
158
+ audience: 'https://app.example.com/api/assistant/oauth/token', // your tokenUrl or configured audience
159
+ });
160
+ if (payload.deployment !== undefined && payload.tenant !== 'your-org/your-app/prod') {
161
+ return new Response(null, { status: 403 }); // optionally pin the calling deployment
162
+ }
163
+ // 3. Mint your own short-lived user-scoped token; your API enforces per-user rules from it.
164
+ const accessToken = await issueAccessToken(String(payload.sub), clientId, form.get('scope') ?? '');
165
+ return Response.json({ access_token: accessToken, token_type: "Bearer", expires_in: 900 });
166
+ }
167
+ ```
168
+
169
+ Diagnose with `noodle auth doctor` (reports each delegated token exchange endpoint and whether managed delegated providers pair with the declared `customerAuth`). Common failures: `disallowed_token_origin` (add the tokenUrl origin to `allowedOrigins`), `unsupported_delegated_provider` (custom provider strings never reach a broker; use `delegatedTokenExchange`), and `delegated token exchange requires a verified customer caller` at runtime (the surface calling the tool has no verified customer identity — check `customerAuth` and, for embeds, `createAssistantSession({ user })`).
100
170
 
101
171
  ## Design tools for the model
102
172
 
@@ -8,6 +8,7 @@
8
8
  - Access modes and customer auth
9
9
  - Create the backend client
10
10
  - Integrate the customer backend
11
+ - Verified session context (identity and claims)
11
12
  - The session response
12
13
  - Mount the browser component
13
14
  - Toolchain requirements
@@ -113,6 +114,50 @@ Authenticate before exchange. Source `origin` from trusted server configuration
113
114
 
114
115
  `serviceUrl` is the Noodle Seed control-plane base URL: the value `noodle assistant clients create` prints, also stored as `serviceUrl` in `deployment.json`. It is NOT the deployment MCP endpoint (`url`, which ends in `/v1/mcp` and rejects session exchange). Never probe or guess endpoints with real credentials.
115
116
 
117
+ ## Verified session context (identity and claims)
118
+
119
+ The embedding developer defines what authenticated session context the assistant receives. One mechanism, three hops:
120
+
121
+ 1. The authenticated backend passes standard identity and any verified claims at session exchange (flat scalars only):
122
+
123
+ ```ts
124
+ const session = await createAssistantSession({
125
+ serviceUrl, clientId, clientSecret, origin,
126
+ user: { id: user.id, email: user.email, name: user.name },
127
+ claims: { displayName: user.name, accountTier: account.tier, region: account.region },
128
+ });
129
+ ```
130
+
131
+ 2. The server author declares the allowlist in `server.ts` — undeclared claims are dropped at session exchange (never rejected, so backend and server deploys may skew safely):
132
+
133
+ ```ts
134
+ assistant: embeddedAssistant({
135
+ model, allowedOrigins,
136
+ sessionClaims: {
137
+ displayName: { exposeToModel: true },
138
+ accountTier: { exposeToModel: true },
139
+ region: {}, // tools only, never in the prompt
140
+ },
141
+ }),
142
+ ```
143
+
144
+ 3. Consumption. Tools read the verified identity and declared claims through the `user` scope:
145
+
146
+ ```ts
147
+ tool("greet", {
148
+ description: "Greet the signed-in user.",
149
+ input: z.object({}),
150
+ annotations: annotations.readOnly(),
151
+ fulfil: ({ user }) => ({ message: `Hello, ${user.name}!`, tier: user.claims.accountTier }),
152
+ });
153
+ ```
154
+
155
+ Manifest expressions use `${user.name}`, `${user.email}`, `${user.subject}`, `${user.claims.<key>}`. The model receives one platform identity line automatically: standard identity (name/email) whenever present, plus only the claims marked `exposeToModel: true` — so the assistant greets the actual user and can pass identity into tool arguments. `noodle check --target embedded-assistant` lists the declared claim contract.
156
+
157
+ Page `context` from the widget remains untrusted hint data; verified facts belong in `claims`, never in `context`.
158
+
159
+ To make the *downstream API call itself* run as the signed-in user (your API enforces its own per-user authorization instead of trusting a forwarded id), give the connector `auth.kind: "delegatedTokenExchange"` — the platform signs a verifiable assertion of this session identity and exchanges it at a token endpoint you implement. Assistant sessions carry the identity this needs; the full contract and a copyable endpoint implementation are in `references/authoring-workflow.md` ("Delegated downstream auth").
160
+
116
161
  ## The session response
117
162
 
118
163
  The exchange returns the versioned Embedded Assistant v1 contract. `token`, `expiresAt`, and `endpoints.turns` / `endpoints.toolConfirmations` (absolute URLs) are always present; `configuration` is optional theming data. Forward the body unchanged; the widget posts turns to `endpoints.turns` itself. Do not rebuild, filter, or rewrite the response.
@@ -142,7 +187,7 @@ The component renders a custom element and must mount client-side. In a Next.js
142
187
  - Signed-out session exchange returns `401`.
143
188
  - The browser network/DOM/storage contains no client secret or model key.
144
189
  - The local and production origins match `allowedOrigins` character-for-character.
145
- - Read-only closed-world tools may auto-run; writes require confirmation.
190
+ - Auto-run requires the full safe-read annotation (`annotations.readOnly()`: read-only, non-destructive, closed-world); unannotated or partially annotated tools always confirm. Writes require confirmation by design.
146
191
  - An expired turn re-exchanges once; confirmations never replay.
147
192
  - Wrong-origin and malformed-origin requests fail closed.
148
193
 
@@ -157,4 +202,11 @@ The component renders a custom element and must mount client-side. In a Next.js
157
202
  | Validate rejects an origin | Non-loopback HTTP origin in `allowedOrigins` | Use the exact HTTPS production origin; HTTP is only for `localhost`/`127.0.0.1` |
158
203
  | Session exchange returns 404 | `serviceUrl` points at the deployment MCP endpoint | Use the control-plane service URL printed by `noodle assistant clients create` |
159
204
  | Session exchange returns 403 `origin is not allowed` | Request origin differs from `allowedOrigins` character-for-character | Align the exact scheme/host/port on both sides and redeploy |
160
- | Hydration or `HTMLElement is not defined` errors | The component mounted during server rendering | Mount client-only (`"use client"` or `next/dynamic` with `ssr: false`) |
205
+ | Hydration or `HTMLElement is not defined` errors | The component mounted during server rendering | Mount client-only (`"use client"` or `next/dynamic` with `ssr: false`) |
206
+ | A read-only tool still asks for confirmation | Its annotations fail the safe-read rule: auto-run requires `readOnlyHint: true`, `destructiveHint: false`, AND `openWorldHint: false` (use `annotations.readOnly()`; `readOnly({ openWorld: true })` confirm-gates) | Fix the annotations; `noodle check --target embedded-assistant` lists every confirm-gated tool |
207
+ | `${user.claims.<key>}` is empty | Claim not declared in `sessionClaims` (or key typo) — undeclared claims are dropped at exchange | Declare the key in `embeddedAssistant({ sessionClaims })` and redeploy |
208
+ | `${user.name}` is empty | Backend did not pass `user.name` to `createAssistantSession` | Pass the verified name from the authenticated backend session |
209
+ | The model does not know a claim you passed | Claim is tools-only | Mark it `exposeToModel: true` in `sessionClaims` |
210
+ | Behavior does not change after `noodle deploy` | Outdated platform: before the 2026-07 fix, clients were pinned to their creation-time deployment | Update the platform; sessions now follow the tenant's active deployment |
211
+ | A delegated connector tool fails with `delegated token exchange requires a verified customer caller` | The calling surface has no verified customer identity (or an old session minted before the platform carried the resource audience) | Verify `customerAuth` is configured and the backend passes the verified `user` to `createAssistantSession`; re-mint the session |
212
+ | Deploy fails with `unsupported_delegated_provider` | `delegatedOAuth.provider` only supports the managed `firebase`/`microsoft` bridges | Use `auth.kind: "delegatedTokenExchange"` for your own token endpoint (see authoring-workflow.md) |
@@ -14,12 +14,12 @@ Paths are relative to this skill directory. Assets (images/fonts) are omitted fr
14
14
  | `acme-discovery` | Top-of-funnel discovery→handoff: a discovery carousel, a `create_handoff` deep link, and a design-first UX spec + wireframe. | `examples/acme-discovery/src/server.ts` + `design/` |
15
15
  | `acme-tasks` | A two-way productivity app designed around its top-3 prioritized flows (capture/prioritize/complete), with a design-first flow spec + wireframe. | `examples/acme-tasks/src/server.ts` + `design/` |
16
16
  | `acme-bistro` | End-to-end ordering with a payment-only handoff; ships a gold-standard `design/` set (UX doc, wireframe with compliance audit, API contract). | `examples/acme-bistro/src/server.ts` + `design/` |
17
+ | `customer-auth` | End-user (customer) auth via OIDC/Firebase bridge with delegated credentials. | `examples/customer-auth/src/server.ts` |
17
18
 
18
19
  ## In the repository only — `examples/<name>/` on GitHub
19
20
 
20
21
  | Example | Use when |
21
22
  | :-- | :-- |
22
- | `customer-auth` | End-user (customer) auth via OIDC/Firebase bridge with delegated credentials. |
23
23
  | `stateful-draft` | Durable, caller-scoped widget state handles with optimistic revisions. |
24
24
  | `perplexity` | A real SaaS API with bearer auth and a managed `secret`. |
25
25
  | `bitcoin` | API-key HTTP connector, custom auth header, and compute normalization. |
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: noodle-seed
3
3
  description: Use when building, validating, testing, deploying, or operating a local or hosted Noodle Seed MCP server or app authored in TypeScript with the noodle CLI.
4
- version: 0.21.0
4
+ version: 0.22.0
5
5
  hash: cd25535e1d6dbf4f
6
6
  ---
7
7
 
@@ -0,0 +1,237 @@
1
+ # Customer Auth - NoodleSeed.com Firebase customer identity
2
+
3
+ This curated example owns the customer/end-user authentication with Firebase bridge capability slot. It is
4
+ the NoodleSeed.com dogfood app for proving that a SaaS app can protect an MCP endpoint with its own customer
5
+ identity provider while still using the generic Noodle Seed authoring API.
6
+
7
+ It also owns the embedded-assistant showcase: the same authenticated MCP surface can be dropped into the
8
+ SaaS web application as a fully customer-branded assistant with independent light and dark themes. The
9
+ assistant loads the active deployment's instructions and model-visible tools rather than installing a stale
10
+ second skill bundle. Interactive MCP Apps widgets continue to render in supported external hosts; the
11
+ initial embedded surface renders text and native tool confirmations.
12
+
13
+ The public developer entrypoint is [`src/server.ts`](src/server.ts). It declares `customerAuth.firebase(...)` with
14
+ the NoodleSeed.com Firebase project and Firebase Web App public config. It exposes a deliberately small MCP
15
+ surface for org discovery:
16
+
17
+ - `list_my_organizations` lists the NoodleSeed.com organizations the signed-in customer belongs to (no
18
+ arguments — the org set comes from the verified customer session).
19
+ - `list_org_apps` lists apps for one of those organizations through the dev app API.
20
+
21
+ The two tools chain: `list_my_organizations` surfaces the `org_id`s the customer can act on, and
22
+ `list_org_apps` takes one of those `org_id`s. There is no NoodleSeed-specific SDK helper. The downstream API
23
+ is an ordinary authored HTTP connector.
24
+
25
+ During MCP OAuth login, Noodle Cloud hosts the Firebase bridge page at
26
+ `https://cloud.noodleseed.dev/oauth/customer/firebase/authorize`. The customer app does not add an
27
+ authorization route. The SaaS operator only configures Firebase Auth to allow the Noodle Cloud origin, and
28
+ Noodle Cloud signs the customer in with Firebase before posting the Firebase ID token back to its own bridge
29
+ callback.
30
+
31
+ ## How delegated customer credentials are used
32
+
33
+ The example has two declarations that work together:
34
+
35
+ ```ts
36
+ auth: customerAuth.firebase({
37
+ projectId: variable('FIREBASE_PROJECT_ID'),
38
+ apiKey: variable('FIREBASE_WEB_API_KEY'),
39
+ authDomain: variable('FIREBASE_AUTH_DOMAIN'),
40
+ user: {
41
+ id: 'sub',
42
+ email: 'email',
43
+ name: 'name',
44
+ tenant: 'firebase.tenant',
45
+ orgs: 'claims.orgs',
46
+ roles: 'claims.roles',
47
+ },
48
+ }),
49
+ ```
50
+
51
+ That protects the MCP endpoint with the SaaS customer's Firebase identity. The connector then opts into
52
+ delegated customer credentials:
53
+
54
+ ```ts
55
+ auth: {
56
+ kind: 'delegatedSessionCookie',
57
+ provider: 'firebase',
58
+ sessionUrl: `${noodleseedApiOrigin}/api/auth/session`,
59
+ tokenField: 'idToken',
60
+ },
61
+ ```
62
+
63
+ Tool code calls the connector normally:
64
+
65
+ ```ts
66
+ fulfil({ input, connectors }) {
67
+ const apps = connectors.app_api.listOrgApps({
68
+ org_id: input.org_id,
69
+ skip: input.skip,
70
+ limit: input.limit,
71
+ });
72
+
73
+ return { result: apps.result };
74
+ }
75
+ ```
76
+
77
+ At runtime, Noodle Seed verifies the Firebase customer during MCP OAuth, stores that customer's delegated
78
+ Firebase refresh token in the credential broker, and refreshes a short-lived Firebase ID token only when a
79
+ connector-backed tool calls the NoodleSeed.com API. For this app API, the broker exchanges that ID token at
80
+ the existing Next.js `/api/auth/session` route and sends the resulting session cookie to the API. The MCP
81
+ access token remains a Noodle-issued resource-bound token and is never sent to the downstream API.
82
+
83
+ ## Delegated downstream auth for your own API (token exchange)
84
+
85
+ The Firebase path above only works for Firebase-session downstreams. When the downstream is **your own
86
+ API** with its own token issuance, use `delegatedTokenExchange` instead
87
+ ([ADR 0152](../../docs/decisions/0152-delegated-token-exchange-connector-auth.md)): the platform signs a
88
+ short-lived, JWKS-verifiable assertion of the signed-in user and exchanges it (RFC 8693) at a token
89
+ endpoint you implement, which mints your own user-scoped token — so your API enforces its own per-user
90
+ authorization on every call. It works for identities from `customerAuth.bridge(...)` and from embedded
91
+ assistant sessions, with no per-user OAuth enrollment.
92
+
93
+ ```ts
94
+ auth: {
95
+ kind: 'delegatedTokenExchange',
96
+ tokenUrl: 'https://app.example.com/api/assistant/oauth/token', // origin must be in allowedOrigins
97
+ clientId: variable('EXAMPLE_DELEG_CLIENT_ID'),
98
+ clientSecret: secret('EXAMPLE_DELEG_CLIENT_SECRET'),
99
+ scopes: ['time_off'],
100
+ },
101
+ ```
102
+
103
+ Your endpoint authenticates the broker's client credential, verifies the `subject_token` JWT against the
104
+ platform issuer JWKS (claims include the verified `sub`, `email`, `name`, declared session `claims`,
105
+ `tenant`, and `deployment`), mints a short-lived user-scoped token, and returns the standard
106
+ `{ access_token, token_type, expires_in }` response. The exact wire contract and a copyable endpoint
107
+ implementation live in [docs/spec/connectors.md](../../docs/spec/connectors.md) and the Agent Kit
108
+ authoring-workflow reference ("Delegated downstream auth"). `noodle auth doctor` reports each declared
109
+ exchange endpoint.
110
+
111
+ ## Validate
112
+
113
+ ```bash
114
+ noodle auth doctor examples/customer-auth/src/server.ts
115
+ noodle validate examples/customer-auth/src/server.ts
116
+ ```
117
+
118
+ ## Run locally
119
+
120
+ ```bash
121
+ noodle dev examples/customer-auth/src/server.ts --app noodleseed-customer-auth
122
+ ```
123
+
124
+ ## Configuration
125
+
126
+ The embedded assistant uses a customer-supplied OpenAI Chat Completions-compatible endpoint. Configure its
127
+ managed values at the Noodle deployment environment; none of these values belongs in the customer web
128
+ application environment, and the API key never reaches the browser:
129
+
130
+ ```bash
131
+ noodle variables set ASSISTANT_MODEL_BASE_URL https://model.example.com/v1 --scope env
132
+ noodle variables set ASSISTANT_MODEL your-model --scope env
133
+ noodle secrets set ASSISTANT_MODEL_API_KEY --scope env
134
+ noodle variables set FIREBASE_PROJECT_ID your-firebase-project --scope env
135
+ noodle variables set FIREBASE_WEB_API_KEY your-firebase-web-api-key --scope env
136
+ noodle variables set FIREBASE_AUTH_DOMAIN your-firebase-project.firebaseapp.com --scope env
137
+ noodle check --target embedded-assistant src/server.ts
138
+ ```
139
+
140
+ Assistant origins are exact and HTTPS-only. A local embedding application must serve itself over HTTPS and
141
+ declare an origin such as `https://localhost:3000`; `noodle dev` does not provide TLS for that separate web
142
+ application.
143
+
144
+ Create the backend credential after deployment. The CLI writes it to a mode-0600 file and never prints the
145
+ secret:
146
+
147
+ ```bash
148
+ noodle assistant clients create --name web --org noodleseed --app customer-auth --env prod
149
+ ```
150
+
151
+ Only the Noodle service URL, assistant client ID, and assistant client secret belong in the authenticated
152
+ customer backend. The model URL, model name, and model API key remain managed by the Noodle deployment.
153
+
154
+ The customer's authenticated backend calls `createAssistantSession(...)` from
155
+ `@noodleseed/assistant/server`, passing the already-verified user and browser origin. The browser then uses
156
+ the returned short-lived session through the Web Component or React wrapper:
157
+
158
+ ```bash
159
+ pnpm add @noodleseed/assistant
160
+ ```
161
+
162
+ ```tsx
163
+ import { NoodleAssistant } from '@noodleseed/assistant/react';
164
+
165
+ <NoodleAssistant
166
+ sessionEndpoint="/api/noodle-assistant/session"
167
+ theme="auto"
168
+ onSessionExpired={() => console.info('Assistant session renewed')}
169
+ />;
170
+ ```
171
+
172
+ `theme="auto"` follows the SaaS application. The server-level `branding` block is inherited by both MCP App
173
+ widgets and the assistant; documented `--ns-assistant-*` semantic CSS variables remain the final integration
174
+ escape hatch. There is no second assistant branding declaration.
175
+ The end-user UI contains only customer branding.
176
+ Text streams progressively. Expired turns re-exchange through the authenticated backend and retry once;
177
+ consent-bound tool confirmations never replay automatically.
178
+
179
+ The Firebase project ID is required because Firebase ID tokens use the project ID as the token audience and
180
+ issuer suffix. The runtime verifies `aud` against the project ID and `iss` against
181
+ `https://securetoken.google.com/<projectId>`.
182
+
183
+ The Firebase Web API key and auth domain are public Firebase browser configuration. They let the Noodle
184
+ Cloud-hosted bridge initialize Firebase Auth for this customer project; they are not server secrets. Keep
185
+ them out of source with `variable(...)`, restrict the Firebase key to the expected browser origins and APIs,
186
+ and use `secret(...)` only for credentials that must never reach a browser.
187
+
188
+ The `noodleseed_app_api` connector currently points at the NoodleSeed.com dev app surface:
189
+
190
+ ```text
191
+ https://dev.noodleseed.com
192
+ ```
193
+
194
+ When the customer app moves from `dev.noodleseed.com` to `app.noodleseed.com`, update the connector's
195
+ `noodleseedApiOrigin` constant to the production API origin that serves the same paths.
196
+
197
+ The connector uses delegated Firebase customer credentials. During the customer OAuth bridge, Noodle Seed
198
+ verifies the Firebase ID token, stores the Firebase refresh token through the credential broker, and refreshes
199
+ a Firebase ID token when the connector calls the NoodleSeed.com app API. The broker then exchanges that ID
200
+ token for the app's existing Next.js session cookie. There is no shared `NOODLESEED_APP_API_TOKEN` for this
201
+ example.
202
+
203
+ Firebase Auth must list `cloud.noodleseed.dev` as an authorized domain before browser sign-in works in
204
+ production.
205
+
206
+ ## Deploy customer-protected to Noodle Seed Cloud
207
+
208
+ ```bash
209
+ noodle deploy examples/customer-auth/src/server.ts \
210
+ --org noodleseed \
211
+ --app customer-auth \
212
+ --env prod \
213
+ --access customers
214
+ ```
215
+
216
+ Endpoint:
217
+
218
+ ```text
219
+ https://cloud.noodleseed.dev/o/noodleseed/customer-auth/mcp
220
+ ```
221
+
222
+ ## MCP Primitives
223
+
224
+ - Tool `list_my_organizations`: calls `GET /api/organizations` and returns the organizations the signed-in
225
+ customer is a member of. Takes no arguments; the org set is scoped by the verified customer session.
226
+ - Tool `list_org_apps`: calls `GET /api/organizations/{org_id}/apps` for one organization `org_id`.
227
+
228
+ ## Auth boundary
229
+
230
+ Firebase ID-token verification is handled by Noodle Seed's generic Firebase bridge adapter during OAuth
231
+ issuance. The MCP client receives a Noodle-issued, resource-bound access token marked as a Firebase customer
232
+ identity; raw Firebase tokens and inbound MCP bearer tokens are never forwarded to tools, connectors,
233
+ widgets, or downstream systems.
234
+
235
+ The connector-backed tools use the credential broker to turn the signed-in Firebase customer session into the
236
+ same session-cookie credential that the existing NoodleSeed.com Next.js API already expects. The inbound MCP
237
+ bearer token is never used as an app API credential.
@@ -0,0 +1,4 @@
1
+ {
2
+ "entrypoint": "src/server.ts",
3
+ "name": "customer-auth"
4
+ }
@@ -0,0 +1,16 @@
1
+ {
2
+ "name": "customer-auth",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "scripts": {
7
+ "test": "vitest run",
8
+ "validate": "noodle validate",
9
+ "dev": "noodle dev",
10
+ "deploy": "noodle deploy"
11
+ },
12
+ "devDependencies": {
13
+ "@noodleseed/one": "latest",
14
+ "vitest": "latest"
15
+ }
16
+ }
@@ -0,0 +1,145 @@
1
+ import {
2
+ annotations,
3
+ connector,
4
+ customerAuth,
5
+ embeddedAssistant,
6
+ openAICompatible,
7
+ secret,
8
+ server,
9
+ tool,
10
+ variable,
11
+ z,
12
+ } from '@noodleseed/one';
13
+
14
+ const noodleseedApiOrigin = 'https://dev.noodleseed.com';
15
+
16
+ const noodleseedApi = connector('noodleseed_app_api')
17
+ .version('1.0.0')
18
+ .http({
19
+ baseUrl: noodleseedApiOrigin,
20
+ allowedOrigins: [noodleseedApiOrigin],
21
+ auth: {
22
+ kind: 'delegatedSessionCookie',
23
+ provider: 'firebase',
24
+ sessionUrl: `${noodleseedApiOrigin}/api/auth/session`,
25
+ tokenField: 'idToken',
26
+ },
27
+ operations: {
28
+ list_org_apps: {
29
+ type: 'read',
30
+ method: 'GET',
31
+ path: '/api/organizations/${args.org_id}/apps',
32
+ query: ['skip', 'limit'],
33
+ input: z.object({
34
+ org_id: z.string(),
35
+ skip: z.number().optional(),
36
+ limit: z.number().optional(),
37
+ }),
38
+ output: z.object({ result: z.unknown().optional() }),
39
+ response: {
40
+ result: '${response}',
41
+ },
42
+ },
43
+ list_organizations: {
44
+ type: 'read',
45
+ method: 'GET',
46
+ path: '/api/organizations',
47
+ output: z.object({ organizations: z.array(z.unknown()).optional() }),
48
+ response: {
49
+ organizations: '${response.organizations}',
50
+ },
51
+ },
52
+ },
53
+ });
54
+
55
+ export default server(
56
+ 'noodleseed_customer_auth',
57
+ {
58
+ title: 'NoodleSeed.com Customer Auth',
59
+ version: '1.0.0',
60
+ branding: {
61
+ name: 'Noodle Seed Assistant',
62
+ accent: '#E85D24',
63
+ surface: '#FFFFFF',
64
+ surfaceDark: '#171310',
65
+ colorScheme: 'auto',
66
+ theme: {
67
+ light: { accentText: '#FFFFFF', text: '#1C1714' },
68
+ dark: { accent: '#FF8A4C', accentText: '#1C100A', text: '#FFF8F2' },
69
+ },
70
+ },
71
+ use: { app_api: noodleseedApi },
72
+ auth: customerAuth.firebase({
73
+ projectId: variable('FIREBASE_PROJECT_ID'),
74
+ apiKey: variable('FIREBASE_WEB_API_KEY'),
75
+ authDomain: variable('FIREBASE_AUTH_DOMAIN'),
76
+ user: {
77
+ id: 'sub',
78
+ email: 'email',
79
+ name: 'name',
80
+ tenant: 'firebase.tenant',
81
+ orgs: 'claims.orgs',
82
+ roles: 'claims.roles',
83
+ },
84
+ }),
85
+ instructions:
86
+ 'Customer-authenticated demo. Firebase proves the customer identity, while read-only NoodleSeed.com API calls use broker-managed delegated Firebase customer credentials.',
87
+ assistant: embeddedAssistant({
88
+ model: openAICompatible({
89
+ baseUrl: variable('ASSISTANT_MODEL_BASE_URL'),
90
+ model: variable('ASSISTANT_MODEL'),
91
+ apiKey: secret('ASSISTANT_MODEL_API_KEY'),
92
+ }),
93
+ // Production origins are exact HTTPS; http://localhost:<port> is allowed for local development.
94
+ allowedOrigins: [
95
+ 'https://app.noodleseed.com',
96
+ 'https://dev.noodleseed.com',
97
+ 'http://localhost:3000',
98
+ ],
99
+ layout: { mode: 'floating', position: 'bottom-right', panelWidth: 420 },
100
+ labels: {
101
+ welcomeHeading: 'How can I help with Noodle Seed?',
102
+ composerPlaceholder: 'Ask about your apps…',
103
+ },
104
+ suggestedPrompts: ['Show my organizations', 'List the apps in my organization'],
105
+ }),
106
+ },
107
+ [
108
+ tool('list_org_apps', {
109
+ description: 'List NoodleSeed.com apps for an organization from the dev app API.',
110
+ input: z.object({
111
+ org_id: z.string(),
112
+ skip: z.number().int().min(0).optional(),
113
+ limit: z.number().int().min(1).max(100).optional(),
114
+ }),
115
+ output: z.object({
116
+ result: z.unknown(),
117
+ }),
118
+ annotations: annotations.readOnly(),
119
+ fulfil({ input, connectors }) {
120
+ const apps = connectors.app_api.listOrgApps({
121
+ org_id: input.org_id,
122
+ skip: input.skip,
123
+ limit: input.limit,
124
+ });
125
+ return {
126
+ result: apps.result,
127
+ };
128
+ },
129
+ }),
130
+ tool('list_my_organizations', {
131
+ description: 'List the NoodleSeed.com organizations the signed-in customer belongs to.',
132
+ input: z.object({}),
133
+ output: z.object({
134
+ organizations: z.array(z.unknown()),
135
+ }),
136
+ annotations: annotations.readOnly(),
137
+ fulfil({ connectors }) {
138
+ const organizations = connectors.app_api.listOrganizations();
139
+ return {
140
+ organizations: organizations.organizations,
141
+ };
142
+ },
143
+ }),
144
+ ],
145
+ );
@@ -0,0 +1,25 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import app from '../src/server.js';
3
+
4
+ describe('customer-auth example', () => {
5
+ it('exports a customer-authenticated, customer-branded embedded assistant', async () => {
6
+ expect(typeof app.toManifest).toBe('function');
7
+ const manifest = await app.toManifest();
8
+ expect(manifest.server.assistant).toMatchObject({
9
+ model: { kind: 'openai-compatible', apiKey: 'ASSISTANT_MODEL_API_KEY' },
10
+ layout: { mode: 'floating' },
11
+ });
12
+ expect(
13
+ manifest.server.assistant?.allowedOrigins.every((origin) => origin.startsWith('https://')),
14
+ ).toBe(true);
15
+ expect(manifest.server.branding).toMatchObject({
16
+ name: 'Noodle Seed Assistant',
17
+ colorScheme: 'auto',
18
+ });
19
+ expect(manifest.server.auth).toMatchObject({
20
+ projectId: '${env.FIREBASE_PROJECT_ID}',
21
+ apiKey: '${env.FIREBASE_WEB_API_KEY}',
22
+ authDomain: '${env.FIREBASE_AUTH_DOMAIN}',
23
+ });
24
+ });
25
+ });
@@ -1,4 +1,4 @@
1
- import { server, tool, z } from '@noodleseed/one';
1
+ import { annotations, server, tool, z } from '@noodleseed/one';
2
2
 
3
3
  export default server(
4
4
  'hello',
@@ -22,6 +22,8 @@ export default server(
22
22
  output: z.object({
23
23
  message: z.string(),
24
24
  }),
25
+ // Read-only, closed-world: assistant surfaces run this without a consent prompt.
26
+ annotations: annotations.readOnly(),
25
27
  fulfil: ({ input }) => {
26
28
  return { message: `Hello, ${input.name}!` };
27
29
  },