@noodleseed/one 0.140.0 → 0.141.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.
Files changed (82) hide show
  1. package/dist/bin.js +0 -0
  2. package/node_modules/@noodle-borg/agent-kit/dist/generated/example-files.js +3 -3
  3. package/node_modules/@noodle-borg/agent-kit/dist/skill-embedded-assistant-ref.js +9 -1
  4. package/node_modules/@noodle-borg/agent-kit/package.json +1 -1
  5. package/node_modules/@noodle-borg/app-package/dist/index.d.ts +1 -0
  6. package/node_modules/@noodle-borg/app-package/dist/index.js +1 -0
  7. package/node_modules/@noodle-borg/{compiler → app-package}/dist/schema-validation.d.ts +2 -1
  8. package/node_modules/@noodle-borg/app-package/package.json +2 -0
  9. package/node_modules/@noodle-borg/assistant-gateway/dist/assistant-app-sandbox.d.ts +13 -0
  10. package/node_modules/@noodle-borg/assistant-gateway/dist/assistant-app-sandbox.js +204 -0
  11. package/node_modules/@noodle-borg/assistant-gateway/dist/assistant-store.d.ts +22 -0
  12. package/node_modules/@noodle-borg/assistant-gateway/dist/assistant-store.js +10 -7
  13. package/node_modules/@noodle-borg/assistant-gateway/dist/assistant-transcript.d.ts +18 -0
  14. package/node_modules/@noodle-borg/assistant-gateway/dist/assistant-transcript.js +6 -0
  15. package/node_modules/@noodle-borg/assistant-gateway/dist/elevation.d.ts +7 -0
  16. package/node_modules/@noodle-borg/assistant-gateway/dist/elevation.js +1 -0
  17. package/node_modules/@noodle-borg/assistant-gateway/dist/portable.d.ts +2 -0
  18. package/node_modules/@noodle-borg/assistant-gateway/dist/portable.js +2 -0
  19. package/node_modules/@noodle-borg/assistant-gateway/dist/public-surface.d.ts +21 -0
  20. package/node_modules/@noodle-borg/assistant-gateway/dist/public-surface.js +39 -0
  21. package/node_modules/@noodle-borg/assistant-gateway/dist/session-resume.d.ts +7 -0
  22. package/node_modules/@noodle-borg/assistant-gateway/dist/session-resume.js +12 -0
  23. package/node_modules/@noodle-borg/assistant-gateway/package.json +1 -1
  24. package/node_modules/@noodle-borg/auth/dist/control-plane-token-exchange.d.ts +110 -0
  25. package/node_modules/@noodle-borg/auth/dist/control-plane-token-exchange.js +217 -0
  26. package/node_modules/@noodle-borg/auth/dist/index.d.ts +1 -0
  27. package/node_modules/@noodle-borg/auth/dist/index.js +1 -0
  28. package/node_modules/@noodle-borg/authoring/dist/assistant.d.ts +5 -0
  29. package/node_modules/@noodle-borg/compiler/dist/index.d.ts +1 -2
  30. package/node_modules/@noodle-borg/compiler/dist/index.js +1 -2
  31. package/node_modules/@noodle-borg/compiler/dist/manifest/schema.d.ts +16 -0
  32. package/node_modules/@noodle-borg/compiler/dist/manifest/schema.js +19 -2
  33. package/node_modules/@noodle-borg/compiler/package.json +0 -2
  34. package/node_modules/@noodle-borg/compute/dist/code-connector.js +44 -1
  35. package/node_modules/@noodle-borg/compute/dist/quickjs-engine.js +82 -266
  36. package/node_modules/@noodle-borg/compute/dist/sandbox-eval.js +276 -0
  37. package/node_modules/@noodle-borg/compute/dist/worker-pool.js +262 -0
  38. package/node_modules/@noodle-borg/{service/dist/auth/deploy-gate.d.ts → control-plane/dist/deploy-gates.d.ts} +10 -6
  39. package/node_modules/@noodle-borg/control-plane/dist/deploy-gates.js +266 -0
  40. package/node_modules/@noodle-borg/control-plane/dist/portable.d.ts +1 -0
  41. package/node_modules/@noodle-borg/control-plane/dist/portable.js +1 -0
  42. package/node_modules/@noodle-borg/developer-mcp/dist/capabilities.js +11 -1
  43. package/node_modules/@noodle-borg/developer-mcp/dist/index.js +1 -1
  44. package/node_modules/@noodle-borg/module/dist/platform-human-identity.d.ts +6 -0
  45. package/node_modules/@noodle-borg/module/dist/platform-human-identity.js +14 -0
  46. package/node_modules/@noodle-borg/protocol/dist/execution-error-mapping.js +13 -1
  47. package/node_modules/@noodle-borg/protocol/dist/observation.d.ts +2 -1
  48. package/node_modules/@noodle-borg/protocol/dist/observation.js +2 -1
  49. package/node_modules/@noodle-borg/runtime/dist/connector/failure-details.d.ts +1 -1
  50. package/node_modules/@noodle-borg/runtime/dist/connector/failure-details.js +5 -0
  51. package/node_modules/@noodle-borg/runtime/dist/connector/types.d.ts +15 -1
  52. package/node_modules/@noodle-borg/runtime/dist/connector/types.js +6 -0
  53. package/node_modules/@noodle-borg/runtime/dist/index.d.ts +1 -1
  54. package/node_modules/@noodle-borg/runtime/dist/index.js +1 -1
  55. package/node_modules/@noodle-borg/runtime/dist/operation-execution.js +1 -0
  56. package/node_modules/@noodle-borg/runtime/dist/state-handles.d.ts +15 -0
  57. package/node_modules/@noodle-borg/runtime/dist/state-handles.js +54 -44
  58. package/node_modules/@noodle-borg/service/dist/auth/developer-grant-guard.js +2 -1
  59. package/node_modules/@noodle-borg/service/dist/modules/route-dispatch.js +1 -1
  60. package/node_modules/@noodle-borg/service/dist/registry-types.d.ts +1 -1
  61. package/node_modules/@noodle-borg/service/dist/routes/assistant-agent.js +15 -3
  62. package/node_modules/@noodle-borg/service/dist/routes/assistant-clients.js +116 -0
  63. package/node_modules/@noodle-borg/service/dist/routes/assistant-dispatch.js +12 -3
  64. package/node_modules/@noodle-borg/service/dist/routes/assistant-elevation.js +1 -0
  65. package/node_modules/@noodle-borg/service/dist/routes/assistant-interaction-stream.js +4 -1
  66. package/node_modules/@noodle-borg/service/dist/routes/assistant-interactions.js +8 -1
  67. package/node_modules/@noodle-borg/service/dist/routes/assistant-public-session.js +3 -9
  68. package/node_modules/@noodle-borg/service/dist/routes/assistant-route-http.js +11 -0
  69. package/node_modules/@noodle-borg/service/dist/routes/assistant-sandbox.js +12 -47
  70. package/node_modules/@noodle-borg/service/dist/routes/assistant-session-target.js +34 -8
  71. package/node_modules/@noodle-borg/service/dist/routes/assistant-transcript.js +27 -0
  72. package/node_modules/@noodle-borg/service/dist/routes/assistant.js +28 -130
  73. package/node_modules/@noodle-borg/service/dist/routes/config-values.js +1 -1
  74. package/node_modules/@noodle-borg/service/dist/routes/control-plane.js +8 -2
  75. package/node_modules/@noodle-borg/service/dist/service.js +15 -1
  76. package/node_modules/@noodle-borg/service/package.json +1 -1
  77. package/node_modules/@noodle-borg/wire-contracts/dist/assistant.d.ts +17 -0
  78. package/node_modules/@noodle-borg/wire-contracts/dist/assistant.js +25 -0
  79. package/node_modules/@noodleseed/assistant/README.md +4 -9
  80. package/node_modules/@noodleseed/assistant/package.json +1 -1
  81. package/package.json +2 -2
  82. /package/node_modules/@noodle-borg/{compiler → app-package}/dist/schema-validation.js +0 -0
@@ -0,0 +1,262 @@
1
+ import { availableParallelism } from 'node:os';
2
+ import { Worker } from 'node:worker_threads';
3
+ import { ComputeError, } from './engine.js';
4
+ const DEFAULT_TERMINATE_GRACE_MS = 500;
5
+ /** Recycle a worker after this many runs to bound WASM heap growth in the shared modules. */
6
+ const WORKER_RECYCLE_AFTER_RUNS = 128;
7
+ function defaultMaxWorkers() {
8
+ return Math.min(4, Math.max(2, availableParallelism() - 1));
9
+ }
10
+ export class ComputeWorkerPool {
11
+ #maxWorkers;
12
+ #terminateGraceMs;
13
+ #slots = [];
14
+ #queue = [];
15
+ #nextRunId = 0;
16
+ #closed = false;
17
+ constructor(options = {}) {
18
+ this.#maxWorkers = Math.max(1, options.maxWorkers ?? defaultMaxWorkers());
19
+ this.#terminateGraceMs = Math.max(0, options.terminateGraceMs ?? DEFAULT_TERMINATE_GRACE_MS);
20
+ }
21
+ /** Run one sandboxed invocation; resolves with the parsed JSON output. */
22
+ execute(job) {
23
+ return new Promise((resolve, reject) => {
24
+ if (this.#closed) {
25
+ reject(new ComputeError('runtime_error', 'compute engine closed'));
26
+ return;
27
+ }
28
+ const enqueuedAt = Date.now();
29
+ const idle = this.#slots.find((slot) => slot.active === null && !slot.dropped);
30
+ if (idle !== undefined) {
31
+ this.#dispatch(idle, job, enqueuedAt, resolve, reject);
32
+ return;
33
+ }
34
+ if (this.#slots.length < this.#maxWorkers) {
35
+ this.#dispatch(this.#spawn(), job, enqueuedAt, resolve, reject);
36
+ return;
37
+ }
38
+ const entry = {
39
+ job,
40
+ enqueuedAt,
41
+ expiry: setTimeout(() => this.#expireQueued(entry), job.limits.timeoutMs),
42
+ resolve,
43
+ reject,
44
+ };
45
+ this.#queue.push(entry);
46
+ });
47
+ }
48
+ /** Reject queued and in-flight work and terminate every worker. */
49
+ async close() {
50
+ if (this.#closed)
51
+ return;
52
+ this.#closed = true;
53
+ for (const entry of this.#queue.splice(0)) {
54
+ clearTimeout(entry.expiry);
55
+ reportTimings(entry.job, Date.now() - entry.enqueuedAt, 0);
56
+ entry.reject(new ComputeError('runtime_error', 'compute engine closed'));
57
+ }
58
+ const slots = this.#slots.splice(0);
59
+ for (const slot of slots) {
60
+ slot.dropped = true;
61
+ const active = slot.active;
62
+ if (active !== null && !active.settled) {
63
+ active.settled = true;
64
+ clearTimeout(active.backstop);
65
+ reportTimings(active.job, active.queueWaitMs, Date.now() - active.startedAt);
66
+ active.reject(new ComputeError('runtime_error', 'compute engine closed'));
67
+ }
68
+ }
69
+ await Promise.all(slots.map((slot) => slot.worker.terminate().catch(() => undefined)));
70
+ }
71
+ #spawn() {
72
+ const worker = new Worker(new URL('./worker-entry.js', import.meta.url), {
73
+ workerData: { noodleComputeWorker: true },
74
+ });
75
+ const slot = { worker, runs: 0, active: null, dropped: false };
76
+ worker.unref();
77
+ worker.on('message', (message) => this.#onMessage(slot, message));
78
+ worker.on('error', () => this.#onWorkerLost(slot));
79
+ worker.on('exit', () => this.#onWorkerLost(slot));
80
+ this.#slots.push(slot);
81
+ return slot;
82
+ }
83
+ #dispatch(slot, job, enqueuedAt, resolve, reject) {
84
+ const startedAt = Date.now();
85
+ const queueWaitMs = Math.max(0, startedAt - enqueuedAt);
86
+ const remainingMs = Math.max(1, job.limits.timeoutMs - queueWaitMs);
87
+ const id = this.#nextRunId;
88
+ this.#nextRunId += 1;
89
+ const active = {
90
+ id,
91
+ job,
92
+ queueWaitMs,
93
+ startedAt,
94
+ backstop: setTimeout(() => this.#onDeadline(slot, active), remainingMs + this.#terminateGraceMs),
95
+ settled: false,
96
+ resolve,
97
+ reject,
98
+ };
99
+ slot.active = active;
100
+ const message = {
101
+ t: 'run',
102
+ id,
103
+ source: job.source,
104
+ inputJson: job.inputJson,
105
+ limits: { ...job.limits, timeoutMs: remainingMs },
106
+ hostEnabled: job.host !== undefined,
107
+ consoleEnabled: job.consoleEnabled,
108
+ };
109
+ slot.worker.postMessage(message);
110
+ }
111
+ #onMessage(slot, message) {
112
+ const active = slot.active;
113
+ if (active === null || active.settled || active.id !== message.id)
114
+ return;
115
+ if (message.t === 'result') {
116
+ active.settled = true;
117
+ clearTimeout(active.backstop);
118
+ slot.active = null;
119
+ reportTimings(active.job, active.queueWaitMs, Date.now() - active.startedAt);
120
+ if (message.outcome.ok) {
121
+ active.resolve(JSON.parse(message.outcome.outputJson));
122
+ }
123
+ else {
124
+ const { code, message: text, path } = message.outcome.error;
125
+ active.reject(new ComputeError(code, text, path));
126
+ }
127
+ this.#release(slot);
128
+ return;
129
+ }
130
+ if (message.t === 'host_call') {
131
+ const host = active.job.host;
132
+ const respond = (envelopeJson) => {
133
+ if (slot.active === active && !active.settled && !slot.dropped) {
134
+ const reply = {
135
+ t: 'host_result',
136
+ callId: message.callId,
137
+ envelopeJson,
138
+ };
139
+ slot.worker.postMessage(reply);
140
+ }
141
+ };
142
+ if (host === undefined) {
143
+ respond(JSON.stringify({
144
+ ok: false,
145
+ error: { code: 'host_call_failed', message: 'host call unavailable' },
146
+ }));
147
+ return;
148
+ }
149
+ host
150
+ .callOperation(message.name, message.argsJson)
151
+ .then(respond)
152
+ .catch(() => respond(JSON.stringify({
153
+ ok: false,
154
+ error: { code: 'host_call_failed', message: 'host call failed' },
155
+ })));
156
+ return;
157
+ }
158
+ // 'log': deliver to the sink (fire-and-forget failures) and always release the sandbox.
159
+ const ack = () => {
160
+ if (slot.active === active && !active.settled && !slot.dropped) {
161
+ const reply = { t: 'log_done', callId: message.callId };
162
+ slot.worker.postMessage(reply);
163
+ }
164
+ };
165
+ const sink = active.job.host?.log;
166
+ if (sink === undefined) {
167
+ ack();
168
+ return;
169
+ }
170
+ Promise.resolve()
171
+ .then(() => sink(message.entry))
172
+ .catch(() => undefined)
173
+ .then(ack);
174
+ }
175
+ /** The queued call's total budget expired before a worker freed: fail fast, stably classified. */
176
+ #expireQueued(entry) {
177
+ const index = this.#queue.indexOf(entry);
178
+ if (index === -1)
179
+ return;
180
+ this.#queue.splice(index, 1);
181
+ reportTimings(entry.job, Date.now() - entry.enqueuedAt, 0);
182
+ entry.reject(new ComputeError('queue_timeout', 'compute queue wait exhausted the call time budget'));
183
+ }
184
+ /** The execution deadline (plus grace) passed without a result: terminate and replace the worker. */
185
+ #onDeadline(slot, active) {
186
+ if (active.settled || slot.active !== active)
187
+ return;
188
+ active.settled = true;
189
+ slot.active = null;
190
+ reportTimings(active.job, active.queueWaitMs, Date.now() - active.startedAt);
191
+ active.reject(new ComputeError('timeout', 'compute exceeded its time budget'));
192
+ this.#drop(slot);
193
+ this.#pumpQueue();
194
+ }
195
+ #onWorkerLost(slot) {
196
+ if (slot.dropped)
197
+ return;
198
+ const active = slot.active;
199
+ if (active !== null && !active.settled) {
200
+ active.settled = true;
201
+ clearTimeout(active.backstop);
202
+ slot.active = null;
203
+ reportTimings(active.job, active.queueWaitMs, Date.now() - active.startedAt);
204
+ active.reject(new ComputeError('runtime_error', 'compute worker exited unexpectedly'));
205
+ }
206
+ this.#drop(slot);
207
+ this.#pumpQueue();
208
+ }
209
+ #release(slot) {
210
+ slot.runs += 1;
211
+ if (slot.runs >= WORKER_RECYCLE_AFTER_RUNS) {
212
+ this.#drop(slot);
213
+ }
214
+ this.#pumpQueue();
215
+ }
216
+ #drop(slot) {
217
+ if (slot.dropped)
218
+ return;
219
+ slot.dropped = true;
220
+ const index = this.#slots.indexOf(slot);
221
+ if (index !== -1)
222
+ this.#slots.splice(index, 1);
223
+ void slot.worker.terminate().catch(() => undefined);
224
+ }
225
+ #pumpQueue() {
226
+ while (this.#queue.length > 0) {
227
+ const slot = this.#slots.find((candidate) => candidate.active === null && !candidate.dropped) ??
228
+ (this.#slots.length < this.#maxWorkers ? this.#spawn() : undefined);
229
+ if (slot === undefined)
230
+ return;
231
+ const entry = this.#queue.shift();
232
+ clearTimeout(entry.expiry);
233
+ // A slot can free in the same tick the expiry timer is due; keep the classification stable
234
+ // by expiring an already-exhausted budget here instead of dispatching with the 1ms floor.
235
+ if (Date.now() - entry.enqueuedAt >= entry.job.limits.timeoutMs) {
236
+ reportTimings(entry.job, Date.now() - entry.enqueuedAt, 0);
237
+ entry.reject(new ComputeError('queue_timeout', 'compute queue wait exhausted the call time budget'));
238
+ continue;
239
+ }
240
+ this.#dispatch(slot, entry.job, entry.enqueuedAt, entry.resolve, entry.reject);
241
+ }
242
+ }
243
+ }
244
+ function reportTimings(job, queueWaitMs, executionMs) {
245
+ try {
246
+ job.onTimings?.({ queueWaitMs, executionMs });
247
+ }
248
+ catch {
249
+ // A telemetry callback must never break the invocation path.
250
+ }
251
+ }
252
+ let shared;
253
+ /**
254
+ * The process-wide default pool. Engines share it so many per-deployment engines on one service
255
+ * instance stay within one bounded worker set; a private pool exists only when an engine is
256
+ * constructed with explicit options (tests, dedicated capacity).
257
+ */
258
+ export function sharedComputeWorkerPool() {
259
+ shared ??= new ComputeWorkerPool();
260
+ return shared;
261
+ }
262
+ //# sourceMappingURL=worker-pool.js.map
@@ -1,7 +1,11 @@
1
1
  import type { IncomingMessage } from 'node:http';
2
- import { type ControlPlaneAuthResult, type DeployAuthGate } from '@noodle-borg/control-plane/portable';
3
2
  import { type PlatformPrincipalResolver } from '@noodle-borg/module';
4
- export { allowAllGate, bearerToken, type ControlPlaneAuthResult, type ControlPlaneIdentity, type DeployAuthGate, } from '@noodle-borg/control-plane/portable';
3
+ import { type ControlPlaneAuthResult, type DeployAuthGate } from './deploy-auth.js';
4
+ /**
5
+ * The concrete control-plane gates (moved verbatim from `packages/service/src/auth/deploy-gate.ts`).
6
+ * Token verification is always injected — the Google `google-auth-library` verifier stays in the
7
+ * hosted service's composition so this package carries no vendor SDK.
8
+ */
5
9
  export interface GoogleIdTokenVerifier {
6
10
  verify(token: string, audience: string | readonly string[]): Promise<{
7
11
  subject: string;
@@ -29,7 +33,7 @@ export interface GoogleControlPlaneGateOptions {
29
33
  readonly signupAuthorizer?: SignupAuthorizer;
30
34
  readonly deniedSignupDomains?: readonly string[];
31
35
  readonly deniedSignupSubjects?: readonly string[];
32
- readonly verifier?: GoogleIdTokenVerifier;
36
+ readonly verifier: GoogleIdTokenVerifier;
33
37
  /** Optional durable canonical-human resolver. Unset preserves direct-Google compatibility. */
34
38
  readonly platformPrincipalResolver?: PlatformPrincipalResolver;
35
39
  }
@@ -44,7 +48,7 @@ export interface GoogleWorkloadControlPlaneGateOptions {
44
48
  /** Exact immutable Google `sub` values; email is never an authorization key. */
45
49
  readonly subjects: readonly string[];
46
50
  readonly admins: readonly string[];
47
- readonly verifier?: GoogleIdTokenVerifier;
51
+ readonly verifier: GoogleIdTokenVerifier;
48
52
  }
49
53
  /**
50
54
  * Separate workload gate retained after direct human Google auth is removed. It deliberately bypasses
@@ -59,7 +63,7 @@ export declare class GoogleWorkloadControlPlaneGate implements DeployAuthGate {
59
63
  /**
60
64
  * Tries several control-plane gates in order and accepts the first that authorizes. On the hosted control
61
65
  * plane a first-party caller may present EITHER an AS-issued token (MCP clients, CLI) OR a Google ID token
62
- * from a trusted audience (the console, [ADR 0116](../../../../docs/decisions/0116-host-the-developer-console.md)) —
66
+ * from a trusted audience (the console, [ADR 0116](../../../docs/decisions/0116-host-the-developer-console.md)) —
63
67
  * restoring the ADR 0039 Google control-plane path alongside the self-hosted AS (ADR 0042). A `403`
64
68
  * (identity verified but denied) is definitive and short-circuits; a `401` (couldn't verify this token) falls
65
69
  * through to the next gate, so each verifier gets a chance at a token it doesn't recognize.
@@ -93,4 +97,4 @@ export declare class NoodleOAuthControlPlaneGate implements DeployAuthGate {
93
97
  constructor(options: NoodleOAuthControlPlaneGateOptions);
94
98
  authorize(req: IncomingMessage): Promise<ControlPlaneAuthResult>;
95
99
  }
96
- //# sourceMappingURL=deploy-gate.d.ts.map
100
+ //# sourceMappingURL=deploy-gates.d.ts.map
@@ -0,0 +1,266 @@
1
+ import { PlatformIdentityError } from '@noodle-borg/module';
2
+ import { bearerToken } from './deploy-auth.js';
3
+ export class GoogleControlPlaneGate {
4
+ #audience;
5
+ #admins;
6
+ #allowedEmailDomain;
7
+ #signupMode;
8
+ #signupAuthorizer;
9
+ #deniedDomains;
10
+ #deniedSubjects;
11
+ #verifier;
12
+ #platformPrincipalResolver;
13
+ constructor(options) {
14
+ const audiences = typeof options.audience === 'string' ? [options.audience] : options.audience;
15
+ if (audiences.length === 0 || audiences.some((value) => value.length === 0)) {
16
+ throw new Error('Google audience must be non-empty');
17
+ }
18
+ // Preserve the configured shape: a lone string stays a string (byte-for-byte unchanged single-audience
19
+ // behavior), a list stays a list. google-auth-library accepts either and matches a token's `aud`.
20
+ this.#audience = options.audience;
21
+ this.#admins = new Set(options.admins.map((value) => value.toLowerCase()));
22
+ this.#allowedEmailDomain = normalizeEmailDomain(options.allowedEmailDomain ?? '@noodleseed.com');
23
+ this.#signupMode = options.signupMode ?? 'restricted';
24
+ this.#signupAuthorizer = options.signupAuthorizer;
25
+ this.#deniedDomains = normalizedDomains(options.deniedSignupDomains ?? []);
26
+ this.#deniedSubjects = new Set(options.deniedSignupSubjects?.map((s) => s.toLowerCase()) ?? []);
27
+ this.#verifier = options.verifier;
28
+ this.#platformPrincipalResolver = options.platformPrincipalResolver;
29
+ }
30
+ async authorize(req) {
31
+ const token = bearerToken(req);
32
+ if (token === null)
33
+ return { ok: false, status: 401, message: 'missing bearer token' };
34
+ let identity;
35
+ try {
36
+ identity = await this.#verifier.verify(token, this.#audience);
37
+ }
38
+ catch {
39
+ return { ok: false, status: 401, message: 'invalid bearer token' };
40
+ }
41
+ const email = identity.email.toLowerCase();
42
+ if (isDenied(identity.subject, email, this.#deniedSubjects, this.#deniedDomains)) {
43
+ return { ok: false, status: 403, message: 'signup is denied' };
44
+ }
45
+ if (this.#signupMode === 'restricted' &&
46
+ !email.endsWith(this.#allowedEmailDomain) &&
47
+ !(await this.#signupAuthorizer?.isSignupAllowed({ subject: identity.subject, email }))) {
48
+ return { ok: false, status: 403, message: 'email domain is not allowed' };
49
+ }
50
+ let identityIssuer;
51
+ if (this.#platformPrincipalResolver !== undefined) {
52
+ try {
53
+ const resolved = await this.#platformPrincipalResolver.resolve({
54
+ provider: 'google',
55
+ realm: 'accounts.google.com',
56
+ subject: identity.subject,
57
+ email,
58
+ emailVerified: true,
59
+ });
60
+ // Google backfill uses its exact existing sub. A resolver that ever violates this would alter the
61
+ // wire-compatible subject boundary, so fail closed rather than accepting an unsafe mapping.
62
+ if (resolved.subject !== identity.subject) {
63
+ return { ok: false, status: 403, message: 'platform identity is not permitted' };
64
+ }
65
+ identityIssuer = resolved.identityIssuer;
66
+ }
67
+ catch (error) {
68
+ if (error instanceof PlatformIdentityError && error.code === 'principal_suspended') {
69
+ return { ok: false, status: 403, message: 'platform identity is suspended' };
70
+ }
71
+ return { ok: false, status: 403, message: 'platform identity is not permitted' };
72
+ }
73
+ }
74
+ return {
75
+ ok: true,
76
+ identity: {
77
+ subject: identity.subject,
78
+ email,
79
+ ...(identityIssuer === undefined ? {} : { identityIssuer }),
80
+ ...(identity.givenName !== undefined ? { givenName: identity.givenName } : {}),
81
+ superAdmin: this.#admins.has(identity.subject.toLowerCase()) || this.#admins.has(email),
82
+ },
83
+ };
84
+ }
85
+ }
86
+ /**
87
+ * Separate workload gate retained after direct human Google auth is removed. It deliberately bypasses
88
+ * principal creation/email evidence: a workload is not a platform human and may act only when its exact
89
+ * Google subject and configured audience both match.
90
+ */
91
+ export class GoogleWorkloadControlPlaneGate {
92
+ #audience;
93
+ #subjects;
94
+ #admins;
95
+ #verifier;
96
+ constructor(options) {
97
+ const audiences = typeof options.audience === 'string' ? [options.audience] : options.audience;
98
+ if (audiences.length === 0 || audiences.some((audience) => audience.trim().length === 0)) {
99
+ throw new Error('Google workload audience must be non-empty');
100
+ }
101
+ if (options.subjects.length === 0 ||
102
+ options.subjects.some((subject) => subject.length === 0 ||
103
+ subject.length > 512 ||
104
+ subject.trim() !== subject ||
105
+ /\s/.test(subject))) {
106
+ throw new Error('Google workload subject allowlist must contain exact non-empty subjects');
107
+ }
108
+ this.#audience = options.audience;
109
+ this.#subjects = new Set(options.subjects);
110
+ this.#admins = new Set(options.admins.map((value) => value.toLowerCase()));
111
+ this.#verifier = options.verifier;
112
+ }
113
+ async authorize(req) {
114
+ const token = bearerToken(req);
115
+ if (token === null)
116
+ return { ok: false, status: 401, message: 'missing bearer token' };
117
+ let identity;
118
+ try {
119
+ identity = await this.#verifier.verify(token, this.#audience);
120
+ }
121
+ catch {
122
+ return { ok: false, status: 401, message: 'invalid bearer token' };
123
+ }
124
+ const email = identity.email.toLowerCase();
125
+ if (!this.#subjects.has(identity.subject)) {
126
+ // A verified service-account token is definitively outside the exact workload allowlist and must not
127
+ // fall through to the temporary broad human-Google compatibility gate. Ordinary human tokens remain
128
+ // verifier-not-applicable so the fallback can handle them during the 14-day window.
129
+ return email.endsWith('.gserviceaccount.com')
130
+ ? { ok: false, status: 403, message: 'workload identity is not permitted' }
131
+ : { ok: false, status: 401, message: 'not a configured workload identity' };
132
+ }
133
+ return {
134
+ ok: true,
135
+ identity: {
136
+ subject: identity.subject,
137
+ email,
138
+ // Workload authority is bound only to the exact immutable Google subject. Email is profile data.
139
+ superAdmin: this.#admins.has(identity.subject.toLowerCase()),
140
+ },
141
+ };
142
+ }
143
+ }
144
+ /**
145
+ * Tries several control-plane gates in order and accepts the first that authorizes. On the hosted control
146
+ * plane a first-party caller may present EITHER an AS-issued token (MCP clients, CLI) OR a Google ID token
147
+ * from a trusted audience (the console, [ADR 0116](../../../docs/decisions/0116-host-the-developer-console.md)) —
148
+ * restoring the ADR 0039 Google control-plane path alongside the self-hosted AS (ADR 0042). A `403`
149
+ * (identity verified but denied) is definitive and short-circuits; a `401` (couldn't verify this token) falls
150
+ * through to the next gate, so each verifier gets a chance at a token it doesn't recognize.
151
+ */
152
+ export class CompositeControlPlaneGate {
153
+ #gates;
154
+ constructor(gates) {
155
+ if (gates.length === 0) {
156
+ throw new Error('composite control-plane gate requires at least one gate');
157
+ }
158
+ this.#gates = gates;
159
+ }
160
+ async authorize(req) {
161
+ let last = { ok: false, status: 401, message: 'invalid bearer token' };
162
+ for (const gate of this.#gates) {
163
+ const result = await gate.authorize(req);
164
+ if (result.ok)
165
+ return result;
166
+ // 403 = identity verified but denied (domain/deny-list): definitive, do not try another verifier.
167
+ if (result.status === 403)
168
+ return result;
169
+ last = result;
170
+ }
171
+ return last;
172
+ }
173
+ }
174
+ export class NoodleOAuthControlPlaneGate {
175
+ #verifier;
176
+ #audiences;
177
+ #admins;
178
+ #allowedEmailDomain;
179
+ #signupMode;
180
+ #signupAuthorizer;
181
+ #deniedDomains;
182
+ #deniedSubjects;
183
+ #platformPrincipalResolver;
184
+ constructor(options) {
185
+ const audiences = typeof options.audience === 'string' ? [options.audience] : options.audience;
186
+ if (audiences.length === 0 || audiences.some((audience) => audience.length === 0)) {
187
+ throw new Error('OAuth audience must be non-empty');
188
+ }
189
+ this.#verifier = options.verifier;
190
+ this.#audiences = [...audiences];
191
+ this.#admins = new Set(options.admins.map((value) => value.toLowerCase()));
192
+ this.#allowedEmailDomain = normalizeEmailDomain(options.allowedEmailDomain ?? '@noodleseed.com');
193
+ this.#signupMode = options.signupMode ?? 'restricted';
194
+ this.#signupAuthorizer = options.signupAuthorizer;
195
+ this.#deniedDomains = normalizedDomains(options.deniedSignupDomains ?? []);
196
+ this.#deniedSubjects = new Set(options.deniedSignupSubjects?.map((s) => s.toLowerCase()) ?? []);
197
+ this.#platformPrincipalResolver = options.platformPrincipalResolver;
198
+ }
199
+ async authorize(req) {
200
+ const token = bearerToken(req);
201
+ if (token === null)
202
+ return { ok: false, status: 401, message: 'missing bearer token' };
203
+ let identity = null;
204
+ for (const audience of this.#audiences) {
205
+ identity = await this.#verifier(token, audience);
206
+ if (identity !== null)
207
+ break;
208
+ }
209
+ if (!identity?.email) {
210
+ return { ok: false, status: 401, message: 'invalid bearer token' };
211
+ }
212
+ const email = identity.email.toLowerCase();
213
+ if (isDenied(identity.subject, email, this.#deniedSubjects, this.#deniedDomains)) {
214
+ return { ok: false, status: 403, message: 'signup is denied' };
215
+ }
216
+ if (this.#signupMode === 'restricted' &&
217
+ !email.endsWith(this.#allowedEmailDomain) &&
218
+ !(await this.#signupAuthorizer?.isSignupAllowed({ subject: identity.subject, email }))) {
219
+ return { ok: false, status: 403, message: 'email domain is not allowed' };
220
+ }
221
+ let identityIssuer;
222
+ if (this.#platformPrincipalResolver !== undefined) {
223
+ try {
224
+ // A Noodle token already has a canonical subject. Do not create an identity from a mutable email claim.
225
+ identityIssuer = (await this.#platformPrincipalResolver.resolveExisting(identity.subject))
226
+ ?.identityIssuer;
227
+ }
228
+ catch (error) {
229
+ if (error instanceof PlatformIdentityError && error.code === 'principal_suspended') {
230
+ return { ok: false, status: 403, message: 'platform identity is suspended' };
231
+ }
232
+ return { ok: false, status: 403, message: 'platform identity is not permitted' };
233
+ }
234
+ }
235
+ return {
236
+ ok: true,
237
+ identity: {
238
+ subject: identity.subject,
239
+ email,
240
+ ...(identityIssuer === undefined ? {} : { identityIssuer }),
241
+ ...(identity.givenName !== undefined ? { givenName: identity.givenName } : {}),
242
+ ...(identity.developerGrantId !== undefined
243
+ ? { developerGrantId: identity.developerGrantId }
244
+ : {}),
245
+ ...(identity.oauthClientId !== undefined ? { oauthClientId: identity.oauthClientId } : {}),
246
+ ...(identity.authTime !== undefined ? { authTime: identity.authTime } : {}),
247
+ superAdmin: identity.developerGrantId === undefined &&
248
+ // Noodle access tokens carry the canonical principal. Mutable email must never grant authority.
249
+ this.#admins.has(identity.subject.toLowerCase()),
250
+ },
251
+ };
252
+ }
253
+ }
254
+ function normalizeEmailDomain(value) {
255
+ const domain = value.trim().toLowerCase();
256
+ if (domain.length === 0)
257
+ throw new Error('allowed email domain must be non-empty');
258
+ return domain.startsWith('@') ? domain : `@${domain}`;
259
+ }
260
+ function normalizedDomains(values) {
261
+ return new Set(values.map(normalizeEmailDomain));
262
+ }
263
+ function isDenied(subject, email, deniedSubjects, deniedDomains) {
264
+ return (deniedSubjects.has(subject.toLowerCase()) || [...deniedDomains].some((d) => email.endsWith(d)));
265
+ }
266
+ //# sourceMappingURL=deploy-gates.js.map
@@ -1,5 +1,6 @@
1
1
  export type { ActiveMcpSubdomainClaim, ChangeMcpSubdomainInput, ControlPlaneIdentity, CreateOrgWithOwnerInput, McpSubdomainMutationResult, McpSubdomainSetting, OrganizationStore, OrgDomainRecord, OrgInvitationRecord, OrgMemberRecord, OrgOpenAIAppsChallengeRecord, OrgRecord, OrgRole, PersonalWorkspaceProvisionInput, PersonalWorkspaceProvisionResult, SignupAllowlistKind, SignupAllowlistRecord, WelcomeEmailRecord, } from './contracts.js';
2
2
  export { allowAllGate, bearerToken, type ControlPlaneAuthResult, type DeployAuthGate, } from './deploy-auth.js';
3
+ export { CompositeControlPlaneGate, type ControlPlaneSignupMode, GoogleControlPlaneGate, type GoogleControlPlaneGateOptions, type GoogleIdTokenVerifier, GoogleWorkloadControlPlaneGate, type GoogleWorkloadControlPlaneGateOptions, NoodleOAuthControlPlaneGate, type NoodleOAuthControlPlaneGateOptions, type SignupAuthorizer, } from './deploy-gates.js';
3
4
  export { InMemoryControlPlaneStore } from './in-memory-control-plane-store.js';
4
5
  export { bindInMemoryOrganizationStore, type InMemoryOrganizationOperations, InMemoryOrganizationStore, } from './in-memory-organization-store.js';
5
6
  export { InMemoryMcpSubdomainClaimStore, McpSubdomainCooldownError, McpSubdomainIdempotencyConflictError, McpSubdomainOwnerRequiredError, type McpSubdomainRouteRef, McpSubdomainUnavailableError, mcpSubdomainEndpointOptions, type ResolvedMcpTenantRef, resolveMcpSubdomainTenant, } from './mcp-subdomain-claims.js';
@@ -1,4 +1,5 @@
1
1
  export { allowAllGate, bearerToken, } from './deploy-auth.js';
2
+ export { CompositeControlPlaneGate, GoogleControlPlaneGate, GoogleWorkloadControlPlaneGate, NoodleOAuthControlPlaneGate, } from './deploy-gates.js';
2
3
  export { InMemoryControlPlaneStore } from './in-memory-control-plane-store.js';
3
4
  export { bindInMemoryOrganizationStore, InMemoryOrganizationStore, } from './in-memory-organization-store.js';
4
5
  export { InMemoryMcpSubdomainClaimStore, McpSubdomainCooldownError, McpSubdomainIdempotencyConflictError, McpSubdomainOwnerRequiredError, McpSubdomainUnavailableError, mcpSubdomainEndpointOptions, resolveMcpSubdomainTenant, } from './mcp-subdomain-claims.js';
@@ -1,5 +1,6 @@
1
1
  export const DEVELOPER_MCP_PATH = '/developer/mcp';
2
2
  export const DEVELOPER_CLI_PATH = '/developer/cli';
3
+ export const DEVELOPER_ASSISTANT_PATH = '/developer/assistant';
3
4
  export const DEVELOPER_CAPABILITIES = [
4
5
  'cloud:read',
5
6
  'deployments:write',
@@ -19,6 +20,13 @@ const CLI_CAPABILITIES = Object.freeze([
19
20
  'deployments:write',
20
21
  'config:write',
21
22
  ]);
23
+ // The onboarding assistant reads the operator's world and deploys the confirm-gated first app —
24
+ // never rollback and never config: the ceiling is structural, so a token minted through the
25
+ // control-plane exchange can never store those capabilities (ADR 0218).
26
+ const ASSISTANT_CAPABILITIES = Object.freeze([
27
+ 'cloud:read',
28
+ 'deployments:write',
29
+ ]);
22
30
  export function isDeveloperCapability(value) {
23
31
  return typeof value === 'string' && DEVELOPER_CAPABILITY_SET.has(value);
24
32
  }
@@ -33,7 +41,7 @@ export function developerResourcePath(resource) {
33
41
  }
34
42
  export function isDeveloperResource(resource) {
35
43
  const path = developerResourcePath(resource);
36
- return path === DEVELOPER_MCP_PATH || path === DEVELOPER_CLI_PATH;
44
+ return (path === DEVELOPER_MCP_PATH || path === DEVELOPER_CLI_PATH || path === DEVELOPER_ASSISTANT_PATH);
37
45
  }
38
46
  export function capabilitiesForDeveloperResource(resource) {
39
47
  const path = developerResourcePath(resource);
@@ -41,6 +49,8 @@ export function capabilitiesForDeveloperResource(resource) {
41
49
  return MCP_CAPABILITIES;
42
50
  if (path === DEVELOPER_CLI_PATH)
43
51
  return CLI_CAPABILITIES;
52
+ if (path === DEVELOPER_ASSISTANT_PATH)
53
+ return ASSISTANT_CAPABILITIES;
44
54
  return [];
45
55
  }
46
56
  //# sourceMappingURL=capabilities.js.map
@@ -1,4 +1,4 @@
1
- export { capabilitiesForDeveloperResource, DEVELOPER_CAPABILITIES, DEVELOPER_CLI_PATH, DEVELOPER_MCP_PATH, developerResourcePath, isDeveloperCapability, isDeveloperResource, } from './capabilities.js';
1
+ export { capabilitiesForDeveloperResource, DEVELOPER_ASSISTANT_PATH, DEVELOPER_CAPABILITIES, DEVELOPER_CLI_PATH, DEVELOPER_MCP_PATH, developerResourcePath, isDeveloperCapability, isDeveloperResource, } from './capabilities.js';
2
2
  export * from './contracts.js';
3
3
  export * from './diagnosis.js';
4
4
  export * from './port.js';
@@ -10,7 +10,13 @@ export interface UpstreamHumanIdentity {
10
10
  export declare function normalizePlatformIdentityEmail(value: string): string;
11
11
  export interface UpstreamAuthorizationOptions {
12
12
  readonly forceAuthentication?: boolean;
13
+ readonly screenHint?: 'sign-up' | 'sign-in';
13
14
  }
15
+ export declare const FRESH_AUTH_SCOPE = "noodle:fresh-auth";
16
+ export declare const SIGNUP_INTENT_SCOPE = "noodle:signup-intent";
17
+ export declare function upstreamAuthorizationOptionsInput(scopes: readonly string[] | undefined, signupClient: boolean): {
18
+ readonly options?: UpstreamAuthorizationOptions;
19
+ };
14
20
  export interface UpstreamHumanAuthentication extends UpstreamHumanIdentity {
15
21
  readonly locale?: string;
16
22
  readonly timeZone?: string;
@@ -6,6 +6,20 @@ export function normalizePlatformIdentityEmail(value) {
6
6
  }
7
7
  return normalized;
8
8
  }
9
+ export const FRESH_AUTH_SCOPE = 'noodle:fresh-auth';
10
+ export const SIGNUP_INTENT_SCOPE = 'noodle:signup-intent';
11
+ export function upstreamAuthorizationOptionsInput(scopes, signupClient) {
12
+ const forceAuthentication = scopes?.includes(FRESH_AUTH_SCOPE) === true;
13
+ const signup = signupClient && scopes?.includes(SIGNUP_INTENT_SCOPE) === true;
14
+ if (!forceAuthentication && !signup)
15
+ return {};
16
+ return {
17
+ options: {
18
+ ...(forceAuthentication ? { forceAuthentication: true } : {}),
19
+ ...(signup ? { screenHint: 'sign-up' } : {}),
20
+ },
21
+ };
22
+ }
9
23
  /** Stable cross-package identity failure used by private providers and public consumers alike. */
10
24
  export class PlatformIdentityError extends Error {
11
25
  code;