@noodleseed/one 0.136.0 → 0.138.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (124) hide show
  1. package/dist/commands/assistant-ops.d.ts.map +1 -1
  2. package/dist/commands/assistant-ops.js +30 -1
  3. package/dist/commands/assistant-ops.js.map +1 -1
  4. package/dist/commands/deploy-first-flow.d.ts +2 -0
  5. package/dist/commands/deploy-first-flow.d.ts.map +1 -1
  6. package/dist/commands/deploy-first-flow.js +46 -14
  7. package/dist/commands/deploy-first-flow.js.map +1 -1
  8. package/dist/commands/deploy-ops.d.ts.map +1 -1
  9. package/dist/commands/deploy-ops.js +27 -1
  10. package/dist/commands/deploy-ops.js.map +1 -1
  11. package/dist/commands/deploy-preflight.d.ts +1 -0
  12. package/dist/commands/deploy-preflight.d.ts.map +1 -1
  13. package/dist/commands/deploy-preflight.js +1 -0
  14. package/dist/commands/deploy-preflight.js.map +1 -1
  15. package/dist/commands/deploy-status-ops.d.ts +1 -0
  16. package/dist/commands/deploy-status-ops.d.ts.map +1 -1
  17. package/dist/commands/deploy-status-ops.js +59 -4
  18. package/dist/commands/deploy-status-ops.js.map +1 -1
  19. package/dist/commands/deployments-ops.d.ts.map +1 -1
  20. package/dist/commands/deployments-ops.js +2 -0
  21. package/dist/commands/deployments-ops.js.map +1 -1
  22. package/dist/commands/diagnostics-ops.d.ts +1 -0
  23. package/dist/commands/diagnostics-ops.d.ts.map +1 -1
  24. package/dist/commands/diagnostics-ops.js +3 -0
  25. package/dist/commands/diagnostics-ops.js.map +1 -1
  26. package/dist/deploy.d.ts +3 -0
  27. package/dist/deploy.d.ts.map +1 -1
  28. package/dist/deploy.js +4 -0
  29. package/dist/deploy.js.map +1 -1
  30. package/dist/index.d.ts +1 -1
  31. package/dist/index.d.ts.map +1 -1
  32. package/dist/index.js +1 -1
  33. package/dist/index.js.map +1 -1
  34. package/node_modules/@noodle-borg/admission-limits/dist/envelope.js +1 -1
  35. package/node_modules/@noodle-borg/agent-kit/dist/generated/example-files.js +4 -4
  36. package/node_modules/@noodle-borg/agent-kit/dist/generated/surface.js +1 -0
  37. package/node_modules/@noodle-borg/agent-kit/dist/skill-embedded-assistant-ref.js +22 -13
  38. package/node_modules/@noodle-borg/agent-kit/package.json +1 -1
  39. package/node_modules/@noodle-borg/assistant-gateway/dist/assistant-store.d.ts +6 -2
  40. package/node_modules/@noodle-borg/assistant-gateway/dist/assistant-store.js +5 -2
  41. package/node_modules/@noodle-borg/assistant-gateway/dist/model-request.js +37 -0
  42. package/node_modules/@noodle-borg/assistant-gateway/dist/model-runtime.js +4 -0
  43. package/node_modules/@noodle-borg/{service/dist/assistant-model-stream.js → assistant-gateway/dist/model-stream.js} +34 -2
  44. package/node_modules/@noodle-borg/assistant-gateway/dist/public-session.d.ts +3 -0
  45. package/node_modules/@noodle-borg/assistant-gateway/dist/public-session.js +2 -1
  46. package/node_modules/@noodle-borg/assistant-gateway/dist/public-turn.d.ts +4 -1
  47. package/node_modules/@noodle-borg/assistant-gateway/dist/public-turn.js +2 -1
  48. package/node_modules/@noodle-borg/assistant-gateway/dist/surface-budget.d.ts +6 -1
  49. package/node_modules/@noodle-borg/assistant-gateway/dist/surface-budget.js +11 -5
  50. package/node_modules/@noodle-borg/assistant-gateway/package.json +5 -1
  51. package/node_modules/@noodle-borg/authoring/dist/assistant.d.ts +10 -2
  52. package/node_modules/@noodle-borg/authoring/dist/assistant.js +4 -0
  53. package/node_modules/@noodle-borg/authoring/dist/index.d.ts +1 -1
  54. package/node_modules/@noodle-borg/authoring/dist/index.js +1 -1
  55. package/node_modules/@noodle-borg/authoring/dist/load-authored.js +1 -1
  56. package/node_modules/@noodle-borg/cli-catalog/dist/catalog-data-assistant.js +17 -0
  57. package/node_modules/@noodle-borg/cli-catalog/dist/catalog-data-hosted-deployment.js +14 -0
  58. package/node_modules/@noodle-borg/compiler/dist/artifact/types.d.ts +1 -7
  59. package/node_modules/@noodle-borg/compiler/dist/manifest/schema.d.ts +20 -8
  60. package/node_modules/@noodle-borg/compiler/dist/manifest/schema.js +12 -8
  61. package/node_modules/@noodle-borg/control-plane/dist/portable.d.ts +2 -0
  62. package/node_modules/@noodle-borg/control-plane/dist/portable.js +1 -0
  63. package/node_modules/@noodle-borg/control-plane/dist/rollback-deployment.d.ts +61 -0
  64. package/node_modules/@noodle-borg/{service/dist/operations → control-plane/dist}/rollback-deployment.js +3 -2
  65. package/node_modules/@noodle-borg/control-plane/dist/rollback-result.d.ts +19 -0
  66. package/node_modules/@noodle-borg/control-plane/package.json +3 -1
  67. package/node_modules/@noodle-borg/developer-mcp/dist/contracts.js +4 -0
  68. package/node_modules/@noodle-borg/developer-mcp/dist/widgets/app-overview.js +1 -1
  69. package/node_modules/@noodle-borg/developer-mcp/dist/widgets/deployment-detail.js +1 -1
  70. package/node_modules/@noodle-borg/module/dist/contract.d.ts +1 -0
  71. package/node_modules/@noodle-borg/module/dist/deployment-automation.d.ts +1 -1
  72. package/node_modules/@noodle-borg/module/dist/endpoint-url.d.ts +11 -0
  73. package/node_modules/@noodle-borg/module/dist/endpoint-url.js +20 -0
  74. package/node_modules/@noodle-borg/module/dist/index.d.ts +1 -0
  75. package/node_modules/@noodle-borg/module/dist/index.js +1 -0
  76. package/node_modules/@noodle-borg/observability/dist/assistant-usage.js +217 -0
  77. package/node_modules/@noodle-borg/observability/dist/index.js +2 -0
  78. package/node_modules/@noodle-borg/{service → observability}/dist/request-event-metrics.js +5 -2
  79. package/node_modules/@noodle-borg/observability/dist/request-events-postgres.js +6 -0
  80. package/node_modules/@noodle-borg/observability/dist/request-events.js +2 -0
  81. package/node_modules/@noodle-borg/service/dist/assistant-bindings.js +3 -1
  82. package/node_modules/@noodle-borg/service/dist/developer-mcp/control-plane-adapter.js +3 -3
  83. package/node_modules/@noodle-borg/service/dist/developer-mcp/inspection.js +2 -1
  84. package/node_modules/@noodle-borg/service/dist/registry-access.js +73 -24
  85. package/node_modules/@noodle-borg/service/dist/registry-deploy-transaction.js +5 -2
  86. package/node_modules/@noodle-borg/service/dist/registry-helpers.d.ts +5 -9
  87. package/node_modules/@noodle-borg/service/dist/registry-helpers.js +4 -18
  88. package/node_modules/@noodle-borg/service/dist/registry-rollback.js +3 -1
  89. package/node_modules/@noodle-borg/service/dist/registry-status.js +4 -1
  90. package/node_modules/@noodle-borg/service/dist/registry-targets.js +4 -4
  91. package/node_modules/@noodle-borg/service/dist/registry-types.d.ts +11 -16
  92. package/node_modules/@noodle-borg/service/dist/registry.d.ts +8 -5
  93. package/node_modules/@noodle-borg/service/dist/registry.js +8 -5
  94. package/node_modules/@noodle-borg/service/dist/routes/access.js +19 -1
  95. package/node_modules/@noodle-borg/service/dist/routes/analytics-dispatch.js +17 -1
  96. package/node_modules/@noodle-borg/service/dist/routes/analytics.js +1 -1
  97. package/node_modules/@noodle-borg/service/dist/routes/assistant-agent.js +73 -59
  98. package/node_modules/@noodle-borg/service/dist/routes/assistant-doctor.js +1 -1
  99. package/node_modules/@noodle-borg/service/dist/routes/assistant-model-binding.js +28 -0
  100. package/node_modules/@noodle-borg/service/dist/routes/assistant-public-session.js +18 -0
  101. package/node_modules/@noodle-borg/service/dist/routes/assistant-public-turn.js +6 -0
  102. package/node_modules/@noodle-borg/service/dist/routes/assistant-usage.js +42 -0
  103. package/node_modules/@noodle-borg/service/dist/routes/assistant.js +20 -3
  104. package/node_modules/@noodle-borg/service/dist/routes/auth-doctor-live.js +1 -2
  105. package/node_modules/@noodle-borg/service/dist/routes/control-plane.js +49 -4
  106. package/node_modules/@noodle-borg/service/dist/routes/deploy-preflight.js +11 -1
  107. package/node_modules/@noodle-borg/service/dist/routes/endpoint-enrichment.js +2 -1
  108. package/node_modules/@noodle-borg/service/dist/routes/paths.js +4 -0
  109. package/node_modules/@noodle-borg/service/dist/routes/rollback.js +1 -1
  110. package/node_modules/@noodle-borg/service/dist/service.js +6 -0
  111. package/node_modules/@noodle-borg/service/dist/store/in-memory.d.ts +2 -5
  112. package/node_modules/@noodle-borg/service/dist/store/in-memory.js +9 -21
  113. package/node_modules/@noodle-borg/service/dist/store/json-file.d.ts +2 -5
  114. package/node_modules/@noodle-borg/service/dist/store/json-file.js +8 -20
  115. package/node_modules/@noodle-borg/service/dist/store/records.js +39 -0
  116. package/node_modules/@noodle-borg/service/dist/store.d.ts +21 -10
  117. package/node_modules/@noodle-borg/service/package.json +5 -1
  118. package/node_modules/@noodle-borg/wire-contracts/dist/control-plane.d.ts +120 -0
  119. package/node_modules/@noodle-borg/wire-contracts/dist/control-plane.js +123 -1
  120. package/node_modules/@noodle-borg/wire-contracts/dist/index.d.ts +4 -0
  121. package/node_modules/@noodle-borg/wire-contracts/dist/index.js +15 -1
  122. package/node_modules/@noodleseed/assistant/README.md +43 -8
  123. package/node_modules/@noodleseed/assistant/package.json +16 -2
  124. package/package.json +2 -2
@@ -8,11 +8,12 @@ import { baseFromRequest, readDeployBody, sendForbidden, sendJson, sendUnauthori
8
8
  import { endpointUrlOptionsForOrg } from '../mcp-public-routing.js';
9
9
  import { ensurePersonalWorkspace } from '../personal-workspace.js';
10
10
  import { manifestUsesUserRoot } from '../registry-access.js';
11
- import { tenantMcpUrl } from '../registry-helpers.js';
12
11
  import { isIdentityAccessMode } from './access-mode.js';
13
12
  import { validateDeployIdempotency } from './deploy-idempotency.js';
14
13
  import { provisionPublicEmbed } from './deploy-public-embed.js';
15
14
  import { respondDeploymentActivationError } from './deployment-activation-response.js';
15
+ import { tenantMcpUrl } from './endpoint-enrichment.js';
16
+ import { canManageMembers } from './org-admin.js';
16
17
  export { handleConfigValues } from './config-values.js';
17
18
  export async function handleDeploy(req, res, registry, options, maxBody, tenant, gate, controlPlane, audit) {
18
19
  // GHD-3: a GitHub run deploys with a run-scoped token on x-noodle-run-token — NOT a control-plane
@@ -66,6 +67,7 @@ export async function handleDeploy(req, res, registry, options, maxBody, tenant,
66
67
  let declaredAccessMode;
67
68
  let orgMembershipSources;
68
69
  let deploymentSource;
70
+ let requestedOwnerSubject, ownerSubject;
69
71
  let serverVersion;
70
72
  try {
71
73
  const raw = JSON.parse(body.text);
@@ -88,6 +90,7 @@ export async function handleDeploy(req, res, registry, options, maxBody, tenant,
88
90
  orgMembershipSources = parsed.data.orgMembershipSources;
89
91
  hostedAssets = parsed.data.hostedAssets;
90
92
  deploymentSource = parsed.data.deploymentSource;
93
+ requestedOwnerSubject = parsed.data.ownerSubject;
91
94
  serverVersion =
92
95
  parsed.data.serverVersion === undefined
93
96
  ? '1'
@@ -96,6 +99,18 @@ export async function handleDeploy(req, res, registry, options, maxBody, tenant,
96
99
  catch (error) {
97
100
  return sendJson(res, 400, { error: `invalid deploy request: ${error.message}` });
98
101
  }
102
+ if (isRunDeploy && requestedOwnerSubject !== undefined) {
103
+ return sendJson(res, 400, {
104
+ error: 'automated deploys cannot select an explicit deployment owner',
105
+ code: 'owner_subject_not_allowed_for_automation',
106
+ });
107
+ }
108
+ if (!isRunDeploy) {
109
+ const selection = await authorizeDeploymentOwnerSelection(res, controlPlane, tenant.org, accessMode, identity, requestedOwnerSubject);
110
+ if (!selection.ok)
111
+ return;
112
+ ownerSubject = selection.ownerSubject;
113
+ }
99
114
  // GHD-3 trusted-phase auth (ADR 0132 Decision 3/4): a run deploy is authorized entirely by its
100
115
  // run token plus the access mode resolved here — the control-plane identity was skipped above.
101
116
  // Every rejection carries a machine-readable `code` so `noodle github runs <id>` can name the
@@ -119,9 +134,6 @@ export async function handleDeploy(req, res, registry, options, maxBody, tenant,
119
134
  ...(provenance?.ownerSubject === undefined
120
135
  ? {}
121
136
  : { previousOwnerSubject: provenance.ownerSubject }),
122
- ...(provenance?.ownerEmail === undefined
123
- ? {}
124
- : { previousOwnerEmail: provenance.ownerEmail }),
125
137
  });
126
138
  if (automation.kind === 'denied') {
127
139
  return sendJson(res, automation.status, {
@@ -145,6 +157,7 @@ export async function handleDeploy(req, res, registry, options, maxBody, tenant,
145
157
  accessMode = automation.accessMode;
146
158
  orgMembershipSources ??= provenance?.orgMembershipSources;
147
159
  identity = automation.actor;
160
+ ownerSubject = automation.ownerSubject;
148
161
  automationId = automation.automationId;
149
162
  }
150
163
  else {
@@ -154,6 +167,12 @@ export async function handleDeploy(req, res, registry, options, maxBody, tenant,
154
167
  });
155
168
  }
156
169
  }
170
+ if (isRunDeploy && accessMode === 'owner-only' && ownerSubject === undefined) {
171
+ return sendJson(res, 400, {
172
+ error: 'owner-only automation requires an existing human owner to preserve',
173
+ code: 'owner_only_unsupported_for_automation',
174
+ });
175
+ }
157
176
  // Identity access modes bind the data plane to verified identity, so the deployer must be authenticated.
158
177
  // (Localhost dev runs the gate open; such deploys are rejected for lack of an owner/audit actor.)
159
178
  if (isIdentityAccessMode(accessMode) && !identity) {
@@ -238,6 +257,7 @@ export async function handleDeploy(req, res, registry, options, maxBody, tenant,
238
257
  connectors,
239
258
  actor: identity || undefined,
240
259
  accessMode,
260
+ ownerSubject,
241
261
  ...(orgMembershipSources !== undefined ? { orgMembershipSources } : {}),
242
262
  hostedAssets,
243
263
  serverVersion,
@@ -339,6 +359,7 @@ export async function handleDeploy(req, res, registry, options, maxBody, tenant,
339
359
  ...(identity?.email !== undefined ? { actorEmail: identity.email } : {}),
340
360
  details: {
341
361
  accessMode: result.accessMode ?? accessMode,
362
+ ...(ownerSubject !== undefined ? { ownerSubject } : {}),
342
363
  serverVersion: result.serverVersion,
343
364
  hasConnectors: connectors !== undefined,
344
365
  },
@@ -388,6 +409,7 @@ export async function handleDeploy(req, res, registry, options, maxBody, tenant,
388
409
  deploymentId: result.deploymentId,
389
410
  serverVersion: result.serverVersion,
390
411
  accessMode: result.accessMode,
412
+ ...(result.ownerSubject !== undefined ? { ownerSubject: result.ownerSubject } : {}),
391
413
  url: tenantMcpUrl(base, tenant, result.serverVersion, endpointOptions),
392
414
  defaultUrl: tenantMcpUrl(base, tenant, undefined, endpointOptions),
393
415
  ...(embedId !== undefined ? { embedId } : {}),
@@ -398,6 +420,29 @@ function normalizeParsedServerVersion(value) {
398
420
  throw new Error('"serverVersion" must be a version string');
399
421
  return normalizeServerVersion(value);
400
422
  }
423
+ export async function authorizeDeploymentOwnerSelection(res, controlPlane, org, accessMode, identity, requestedOwnerSubject) {
424
+ if (accessMode !== 'owner-only')
425
+ return { ok: true };
426
+ if (identity === undefined) {
427
+ sendJson(res, 401, {
428
+ error: 'owner-only deployments require an authenticated deployer',
429
+ code: 'deployer_identity_required',
430
+ });
431
+ return { ok: false };
432
+ }
433
+ if (requestedOwnerSubject === undefined || requestedOwnerSubject === identity.subject) {
434
+ return { ok: true, ownerSubject: identity.subject };
435
+ }
436
+ if (identity.developerGrantId !== undefined ||
437
+ !(await canManageMembers(controlPlane, org, identity))) {
438
+ sendJson(res, 403, {
439
+ error: 'Selecting a different deployment owner requires organization owner access.',
440
+ code: 'deployment_owner_authorization_required',
441
+ });
442
+ return { ok: false };
443
+ }
444
+ return { ok: true, ownerSubject: requestedOwnerSubject };
445
+ }
401
446
  export async function handleWhoami(req, res, gate, controlPlane, options = {}) {
402
447
  // Identity introspection names no org or environment, so no grant context can describe it.
403
448
  const identity = await authorizeControlPlane(req, res, gate, {
@@ -3,7 +3,7 @@ import { deployPreflightRequestSchema, formatWireError } from '@noodle-borg/wire
3
3
  import { readDeployBody, sendJson } from '../http-util.js';
4
4
  import { manifestUsesUserRoot } from '../registry-access.js';
5
5
  import { isIdentityAccessMode } from './access-mode.js';
6
- import { authorizeTenantControl, developerGrantRouteAccess } from './control-plane.js';
6
+ import { authorizeDeploymentOwnerSelection, authorizeTenantControl, developerGrantRouteAccess, } from './control-plane.js';
7
7
  /**
8
8
  * Read-only first-deploy readiness. It intentionally does not verify uploaded asset bytes:
9
9
  * the CLI calls this before it requests upload targets or sends any bytes.
@@ -49,6 +49,9 @@ export async function handleDeployPreflight(req, res, registry, options, maxBody
49
49
  error: `${accessMode} deployments require an authenticated deployer`,
50
50
  });
51
51
  }
52
+ const ownerSelection = await authorizeDeploymentOwnerSelection(res, controlPlane, tenant.org, accessMode, identity, parsed.ownerSubject);
53
+ if (!ownerSelection.ok)
54
+ return;
52
55
  if (accessMode === 'public' && manifestUsesUserRoot(parsed.manifest)) {
53
56
  return sendJson(res, 400, {
54
57
  code: 'public_user_context_conflict',
@@ -71,6 +74,7 @@ export async function handleDeployPreflight(req, res, registry, options, maxBody
71
74
  ...(parsed.connectors !== undefined ? { connectors: parsed.connectors } : {}),
72
75
  actor: identity,
73
76
  accessMode,
77
+ ownerSubject: ownerSelection.ownerSubject,
74
78
  ...(parsed.orgMembershipSources !== undefined
75
79
  ? { orgMembershipSources: parsed.orgMembershipSources }
76
80
  : {}),
@@ -99,6 +103,9 @@ export async function handleDeployPreflight(req, res, registry, options, maxBody
99
103
  ready,
100
104
  appState,
101
105
  environmentState,
106
+ ...(ownerSelection.ownerSubject !== undefined
107
+ ? { ownerSubject: ownerSelection.ownerSubject }
108
+ : {}),
102
109
  errorCount: errors.length,
103
110
  codes,
104
111
  missingSecretCount: missingSecrets.length,
@@ -108,6 +115,9 @@ export async function handleDeployPreflight(req, res, registry, options, maxBody
108
115
  return sendJson(res, 200, {
109
116
  ok: true,
110
117
  ready,
118
+ ...(ownerSelection.ownerSubject !== undefined
119
+ ? { ownerSubject: ownerSelection.ownerSubject }
120
+ : {}),
111
121
  target: {
112
122
  ...tenant,
113
123
  appState,
@@ -1,4 +1,5 @@
1
- import { tenantMcpUrl } from '../registry-helpers.js';
1
+ import { tenantMcpUrl } from '@noodle-borg/module';
2
+ export { tenantMcpUrl };
2
3
  /** Stamp `endpointUrl` onto one deployment summary. */
3
4
  export function withEndpointUrl(summary, base, options = {}) {
4
5
  return {
@@ -204,6 +204,10 @@ export function parseTenantSessionPath(pathname) {
204
204
  sessionId: decodeSegment(match, 4),
205
205
  }));
206
206
  }
207
+ /** `GET .../assistant/usage` — scalar-only embedded assistant engagement and model usage. */
208
+ export function parseTenantAssistantUsagePath(pathname) {
209
+ return matchPath(pathname, /^\/v1\/orgs\/([^/]+)\/apps\/([^/]+)\/envs\/([^/]+)\/assistant\/usage$/, tenantRef);
210
+ }
207
211
  /** Match `/v1/orgs/{org}/apps/{app}/github/connection` (GHD-1 repository connection, GET/POST/DELETE). */
208
212
  export function parseGithubConnectionPath(pathname) {
209
213
  return matchPath(pathname, /^\/v1\/orgs\/([^/]+)\/apps\/([^/]+)\/github\/connection$/, appRef);
@@ -1,6 +1,6 @@
1
+ import { rollbackDeploymentOperation } from '@noodle-borg/control-plane/portable';
1
2
  import { baseFromRequest, readJsonBody, sendForbidden, sendJson } from '../http-util.js';
2
3
  import { endpointUrlOptionsForOrg } from '../mcp-public-routing.js';
3
- import { rollbackDeploymentOperation } from '../operations/rollback-deployment.js';
4
4
  import { authorizeTenantControl, developerGrantRouteAccess } from './control-plane.js';
5
5
  export async function handleRollback(req, res, registry, gate, controlPlane, audit, maxBody, tenant, options) {
6
6
  const identity = await authorizeTenantControl(req, res, gate, controlPlane, tenant.org, developerGrantRouteAccess(options.developerGrantStore, 'deployments:rollback'));
@@ -253,6 +253,12 @@ export function createServiceHandler(registry, options = {}) {
253
253
  ...(options.assistantModelFetch !== undefined
254
254
  ? { modelFetch: options.assistantModelFetch }
255
255
  : {}),
256
+ ...(options.managedAssistantModelResolver !== undefined
257
+ ? { managedModelResolver: options.managedAssistantModelResolver }
258
+ : {}),
259
+ ...(options.captureRequestEvent !== undefined
260
+ ? { captureRequestEvent: options.captureRequestEvent }
261
+ : {}),
256
262
  ...(options.clock !== undefined ? { clock: options.clock } : {}),
257
263
  logger,
258
264
  applySecurityHeaders,
@@ -1,5 +1,5 @@
1
1
  import type { AccessMode } from '@noodle-borg/transport-http';
2
- import type { AppArchiveResult, AppRestorePrecondition, AppRestoreResult, AppSummary, ArtifactStore, DeploymentActivationResult, DeploymentListFilter, DeploymentLock, DeploymentLockUpdateResult, DeploymentSummary, DeployRecord, EnvSummary, ProductionEnvironmentChange, TenantAuthConfig, TenantRef } from '../store.js';
2
+ import type { ActiveAccessUpdateInput, AppArchiveResult, AppRestorePrecondition, AppRestoreResult, AppSummary, ArtifactStore, DeploymentActivationResult, DeploymentListFilter, DeploymentLock, DeploymentLockUpdateResult, DeploymentSummary, DeployRecord, EnvSummary, ProductionEnvironmentChange, TenantAuthConfig, TenantRef } from '../store.js';
3
3
  /** In-memory store: keeps records in a Map. Used by tests and as an explicit, non-persistent option. */
4
4
  export declare class InMemoryArtifactStore implements ArtifactStore {
5
5
  #private;
@@ -16,10 +16,7 @@ export declare class InMemoryArtifactStore implements ArtifactStore {
16
16
  }, _options?: {
17
17
  readonly automationId?: string;
18
18
  }): Promise<DeploymentActivationResult | undefined>;
19
- updateActiveAccess(ref: TenantRef, deploymentId: string, accessMode: AccessMode, precondition: {
20
- readonly expectedAccessMode: AccessMode | undefined;
21
- readonly serverAuth?: TenantAuthConfig;
22
- }): Promise<DeployRecord | undefined>;
19
+ updateActiveAccess(ref: TenantRef, deploymentId: string, input: ActiveAccessUpdateInput): Promise<DeployRecord | undefined>;
23
20
  listDeployments(filter: DeploymentListFilter): Promise<readonly DeploymentSummary[]>;
24
21
  getAppArchivedAt(org: string, app: string): Promise<string | undefined>;
25
22
  archiveApp(org: string, app: string, at: string): Promise<AppArchiveResult | undefined>;
@@ -3,7 +3,7 @@ import { assertSameAppPackageSnapshot, sanitizeDeployRecordAppPackageSnapshot, }
3
3
  import { assertCustomerAuthRestorePrecondition, assertUniqueActiveCustomerAuthAudienceBindings, findActiveCustomerAuthAudienceConflict, } from '../customer-auth-audience-binding.js';
4
4
  import { assertDeploymentActivationUnlocked, assertDeploymentAppendUnlocked, } from '../deployment-lock.js';
5
5
  import { defaultActiveRecord, sameDeploymentScope, sameTenantRecord, } from '../deployment-versioning.js';
6
- import { appArchivedAt, deploymentSummary, matchesDeploymentFilter, paginateAppSummaries, planAppArchive, planAppRestore, resolveProductionEnvironment, summarizeApps, summarizeEnvs, validateDeploymentListFilter, withoutArchiveStamp, } from './records.js';
6
+ import { appArchivedAt, deploymentSummary, matchesDeploymentFilter, paginateAppSummaries, planAppArchive, planAppRestore, preserveDeploymentOwnerState, resolveActiveAccessUpdate, resolveProductionEnvironment, summarizeApps, summarizeEnvs, validateDeploymentListFilter, withoutArchiveStamp, } from './records.js';
7
7
  import { validateSlug, validateTenantRef } from './validate.js';
8
8
  /** In-memory store: keeps records in a Map. Used by tests and as an explicit, non-persistent option. */
9
9
  export class InMemoryArtifactStore {
@@ -14,6 +14,8 @@ export class InMemoryArtifactStore {
14
14
  if (existing !== undefined)
15
15
  assertSameAppPackageSnapshot(existing, record);
16
16
  record = sanitizeDeployRecordAppPackageSnapshot(record);
17
+ if (existing !== undefined)
18
+ record = preserveDeploymentOwnerState(existing, record);
17
19
  validateTenantRef({ org: record.orgSlug, app: record.appSlug, env: record.environment });
18
20
  if (!assertDeploymentAppendUnlocked([...this.#records.values()], record))
19
21
  return;
@@ -117,29 +119,15 @@ export class InMemoryArtifactStore {
117
119
  }
118
120
  }
119
121
  }
120
- async updateActiveAccess(ref, deploymentId, accessMode, precondition) {
122
+ async updateActiveAccess(ref, deploymentId, input) {
121
123
  const safe = validateTenantRef(ref);
122
124
  const record = this.#records.get(deploymentId);
123
- if (record === undefined ||
124
- !record.active ||
125
- record.archivedAt !== undefined ||
126
- !sameTenantRecord(record, safe) ||
127
- record.accessMode !== precondition.expectedAccessMode) {
128
- return Promise.resolve(undefined);
129
- }
130
- if ((record.accessMode ?? 'owner-only') === accessMode && accessMode !== 'customers') {
131
- return Promise.resolve(record);
132
- }
133
- const updated = {
134
- ...record,
135
- accessMode,
136
- ...(accessMode === 'customers' && precondition.serverAuth !== undefined
137
- ? { serverAuth: precondition.serverAuth }
138
- : {}),
139
- };
140
- const proposed = new Map(this.#records).set(record.deploymentId, updated);
125
+ const updated = resolveActiveAccessUpdate(record, safe, input);
126
+ if (updated === undefined || updated === record)
127
+ return Promise.resolve(updated);
128
+ const proposed = new Map(this.#records).set(updated.deploymentId, updated);
141
129
  assertUniqueActiveCustomerAuthAudienceBindings([...proposed.values()]);
142
- this.#records.set(record.deploymentId, updated);
130
+ this.#records.set(updated.deploymentId, updated);
143
131
  return Promise.resolve(updated);
144
132
  }
145
133
  listDeployments(filter) {
@@ -1,5 +1,5 @@
1
1
  import type { AccessMode } from '@noodle-borg/transport-http';
2
- import type { AppArchiveResult, AppRestorePrecondition, AppRestoreResult, AppSummary, ArtifactStore, DeploymentActivationResult, DeploymentListFilter, DeploymentLock, DeploymentLockUpdateResult, DeploymentSummary, DeployRecord, EnvSummary, ProductionEnvironmentChange, TenantAuthConfig, TenantRef } from '../store.js';
2
+ import type { ActiveAccessUpdateInput, AppArchiveResult, AppRestorePrecondition, AppRestoreResult, AppSummary, ArtifactStore, DeploymentActivationResult, DeploymentListFilter, DeploymentLock, DeploymentLockUpdateResult, DeploymentSummary, DeployRecord, EnvSummary, ProductionEnvironmentChange, TenantAuthConfig, TenantRef } from '../store.js';
3
3
  /**
4
4
  * JSON-file store: one file per deployment at `<dataDir>/deployments/<deploymentId>.json`, written atomically
5
5
  * (write a temp file, then `rename` over the target — no partial file is ever read). `deploymentId` is a
@@ -15,10 +15,7 @@ export declare class JsonFileArtifactStore implements ArtifactStore {
15
15
  getActiveByTenantVersion(ref: TenantRef, serverVersion: string): Promise<DeployRecord | undefined>;
16
16
  setDeploymentLock(ref: TenantRef, serverVersion: string, expectedDeploymentId: string, deploymentLock: DeploymentLock | undefined): Promise<DeploymentLockUpdateResult>;
17
17
  findActiveCustomerAuthAudienceConflict(ref: TenantRef, auth: TenantAuthConfig): Promise<TenantRef | undefined>;
18
- updateActiveAccess(ref: TenantRef, deploymentId: string, accessMode: AccessMode, precondition: {
19
- readonly expectedAccessMode: AccessMode | undefined;
20
- readonly serverAuth?: TenantAuthConfig;
21
- }): Promise<DeployRecord | undefined>;
18
+ updateActiveAccess(ref: TenantRef, deploymentId: string, input: ActiveAccessUpdateInput): Promise<DeployRecord | undefined>;
22
19
  activateDeployment(ref: TenantRef, deploymentId: string, precondition?: {
23
20
  readonly expectedAccessMode: AccessMode | undefined;
24
21
  readonly serverAuth?: TenantAuthConfig;
@@ -6,7 +6,7 @@ import { assertSameAppPackageSnapshot, sanitizeDeployRecordAppPackageSnapshot, }
6
6
  import { assertCustomerAuthRestorePrecondition, assertUniqueActiveCustomerAuthAudienceBindings, findActiveCustomerAuthAudienceConflict, } from '../customer-auth-audience-binding.js';
7
7
  import { assertDeploymentActivationUnlocked, assertDeploymentAppendUnlocked, } from '../deployment-lock.js';
8
8
  import { defaultActiveRecord, sameDeploymentScope, sameTenantRecord, } from '../deployment-versioning.js';
9
- import { appArchivedAt, deploymentSummary, matchesDeploymentFilter, paginateAppSummaries, planAppArchive, planAppRestore, resolveProductionEnvironment, summarizeApps, summarizeEnvs, validateDeploymentListFilter, withoutArchiveStamp, } from './records.js';
9
+ import { appArchivedAt, deploymentSummary, matchesDeploymentFilter, paginateAppSummaries, planAppArchive, planAppRestore, preserveDeploymentOwnerState, resolveActiveAccessUpdate, resolveProductionEnvironment, summarizeApps, summarizeEnvs, validateDeploymentListFilter, withoutArchiveStamp, } from './records.js';
10
10
  import { DEPLOYMENT_ID_PATTERN, validateSlug, validateTenantRef } from './validate.js';
11
11
  /**
12
12
  * JSON-file store: one file per deployment at `<dataDir>/deployments/<deploymentId>.json`, written atomically
@@ -38,6 +38,8 @@ export class JsonFileArtifactStore {
38
38
  if (existing !== undefined)
39
39
  assertSameAppPackageSnapshot(existing, record);
40
40
  record = sanitizeDeployRecordAppPackageSnapshot(record);
41
+ if (existing !== undefined)
42
+ record = preserveDeploymentOwnerState(existing, record);
41
43
  if (!assertDeploymentAppendUnlocked(records, record))
42
44
  return;
43
45
  const proposed = new Map(records.map((item) => [item.deploymentId, item]));
@@ -104,28 +106,14 @@ export class JsonFileArtifactStore {
104
106
  async findActiveCustomerAuthAudienceConflict(ref, auth) {
105
107
  return findActiveCustomerAuthAudienceConflict(await this.loadAll(), validateTenantRef(ref), auth);
106
108
  }
107
- async updateActiveAccess(ref, deploymentId, accessMode, precondition) {
109
+ async updateActiveAccess(ref, deploymentId, input) {
108
110
  const safe = validateTenantRef(ref);
109
111
  return this.#serializeLifecycle(async () => {
110
112
  const record = await this.get(deploymentId);
111
- if (record === undefined ||
112
- !record.active ||
113
- record.archivedAt !== undefined ||
114
- !sameTenantRecord(record, safe) ||
115
- record.accessMode !== precondition.expectedAccessMode) {
116
- return undefined;
117
- }
118
- if ((record.accessMode ?? 'owner-only') === accessMode && accessMode !== 'customers') {
119
- return record;
120
- }
121
- const updated = {
122
- ...record,
123
- accessMode,
124
- ...(accessMode === 'customers' && precondition.serverAuth !== undefined
125
- ? { serverAuth: precondition.serverAuth }
126
- : {}),
127
- };
128
- const proposed = new Map((await this.loadAll()).map((item) => [item.deploymentId, item])).set(record.deploymentId, updated);
113
+ const updated = resolveActiveAccessUpdate(record, safe, input);
114
+ if (updated === undefined || updated === record)
115
+ return updated;
116
+ const proposed = new Map((await this.loadAll()).map((item) => [item.deploymentId, item])).set(updated.deploymentId, updated);
129
117
  assertUniqueActiveCustomerAuthAudienceBindings([...proposed.values()]);
130
118
  await this.#writeRecord(updated);
131
119
  return updated;
@@ -1,6 +1,16 @@
1
+ /**
2
+ * Pure record-level helpers shared by every {@link ArtifactStore} backend: the canonical
3
+ * deployment-summary mapping, list filtering, app archive/restore planning (ADR 0117), and the
4
+ * `apps` resource aggregation (`summarizeApps`). No I/O here — backends apply the plans to their
5
+ * own persistence and feed their own raw records/anchors through these pure functions so every
6
+ * backend groups, ranks, and sorts identically.
7
+ */
8
+ import { sameTenantRecord } from '../deployment-versioning.js';
9
+ import { deploymentOwnerSubject } from '../registry-helpers.js';
1
10
  import { validateSlug } from './validate.js';
2
11
  /** The canonical, secret-free list projection of a {@link DeployRecord}. */
3
12
  export function deploymentSummary(record) {
13
+ const ownerSubject = deploymentOwnerSubject(record);
4
14
  return {
5
15
  deploymentId: record.deploymentId,
6
16
  orgSlug: record.orgSlug,
@@ -11,6 +21,7 @@ export function deploymentSummary(record) {
11
21
  serverName: record.serverName,
12
22
  createdAt: record.createdAt,
13
23
  ...(record.createdByEmail !== undefined ? { createdByEmail: record.createdByEmail } : {}),
24
+ ...(ownerSubject !== undefined ? { ownerSubject } : {}),
14
25
  ...(record.deploymentSource !== undefined ? { deploymentSource: record.deploymentSource } : {}),
15
26
  accessMode: record.accessMode ?? 'owner-only',
16
27
  ...(record.deploymentLock !== undefined
@@ -95,6 +106,34 @@ export function withoutArchiveStamp(record) {
95
106
  const { archivedAt: _cleared, ...rest } = record;
96
107
  return rest;
97
108
  }
109
+ export function preserveDeploymentOwnerState(existing, candidate) {
110
+ const { ownerSubject: _candidateOwnerSubject, ...deployFields } = candidate;
111
+ return {
112
+ ...deployFields,
113
+ ...(existing.ownerSubject !== undefined ? { ownerSubject: existing.ownerSubject } : {}),
114
+ };
115
+ }
116
+ export function resolveActiveAccessUpdate(record, ref, input) {
117
+ if (record === undefined ||
118
+ !record.active ||
119
+ record.archivedAt !== undefined ||
120
+ !sameTenantRecord(record, ref) ||
121
+ record.accessMode !== input.expectedAccessMode ||
122
+ deploymentOwnerSubject(record) !== input.expectedOwnerSubject)
123
+ return undefined;
124
+ if ((record.accessMode ?? 'owner-only') === input.accessMode &&
125
+ (input.ownerSubject === undefined || deploymentOwnerSubject(record) === input.ownerSubject) &&
126
+ input.accessMode !== 'customers')
127
+ return record;
128
+ return {
129
+ ...record,
130
+ accessMode: input.accessMode,
131
+ ...(input.ownerSubject !== undefined ? { ownerSubject: input.ownerSubject } : {}),
132
+ ...(input.accessMode === 'customers' && input.serverAuth !== undefined
133
+ ? { serverAuth: input.serverAuth }
134
+ : {}),
135
+ };
136
+ }
98
137
  const ENVIRONMENT_RANK = { prod: 0, staging: 1, dev: 2 };
99
138
  function environmentRank(environment) {
100
139
  return ENVIRONMENT_RANK[environment] ?? 3;
@@ -47,6 +47,8 @@ export interface DeployRecord {
47
47
  readonly createdAt: string;
48
48
  readonly createdBySubject?: string;
49
49
  readonly createdByEmail?: string;
50
+ /** OAuth subject authorized by owner-only access; absent legacy records fall back to the creator. */
51
+ readonly ownerSubject?: string;
50
52
  /** Informational deploy origin. Absent on records written before source tracking. */
51
53
  readonly deploymentSource?: DeploymentSource;
52
54
  /**
@@ -142,6 +144,7 @@ export interface DeploymentSummary {
142
144
  readonly serverName: string;
143
145
  readonly createdAt: string;
144
146
  readonly createdByEmail?: string;
147
+ readonly ownerSubject?: string;
145
148
  readonly deploymentSource?: DeploymentSource;
146
149
  readonly accessMode: AccessMode;
147
150
  readonly deploymentLock?: DeploymentLockMetadata;
@@ -236,6 +239,7 @@ export interface DeploymentStatus {
236
239
  readonly serverName: string;
237
240
  readonly createdAt: string;
238
241
  readonly createdByEmail?: string;
242
+ readonly ownerSubject?: string;
239
243
  readonly accessMode: AccessMode;
240
244
  readonly deploymentLock?: DeploymentLockMetadata;
241
245
  };
@@ -252,6 +256,16 @@ export interface DeploymentActivationResult {
252
256
  readonly previousActive?: DeployRecord;
253
257
  readonly alreadyActive: boolean;
254
258
  }
259
+ /** One atomic access/owner compare-and-set over the active deployment's observed state. */
260
+ export interface ActiveAccessUpdateInput {
261
+ readonly accessMode: AccessMode;
262
+ /** Desired owner mutation. Omission preserves the current effective owner; clearing is unsupported. */
263
+ readonly ownerSubject?: string;
264
+ readonly expectedAccessMode: AccessMode | undefined;
265
+ readonly expectedOwnerSubject: string | undefined;
266
+ /** Compiler-authoritative projection persisted atomically when enabling customer access. */
267
+ readonly serverAuth?: TenantAuthConfig;
268
+ }
255
269
  export type DeploymentLockUpdateResult = {
256
270
  readonly ok: true;
257
271
  readonly record: DeployRecord;
@@ -263,9 +277,10 @@ export type DeploymentLockUpdateResult = {
263
277
  export interface ControlPlaneStore extends OrganizationStore {
264
278
  }
265
279
  /**
266
- * Durable storage for deployed-server records. `append` writes (or replaces) one server's record;
267
- * `loadAll` returns every current record for restart recovery. Historical records are retained so a
268
- * tenant environment can reactivate an earlier deployment without changing the tenant-facing URL.
280
+ * Durable storage for deployed-server records. `append` writes (or replaces) one server's immutable deploy
281
+ * inputs while preserving owner state changed through {@link updateActiveAccess}; `loadAll` returns every
282
+ * current record for restart recovery. Historical records are retained so a tenant environment can reactivate
283
+ * an earlier deployment without changing the tenant-facing URL.
269
284
  */
270
285
  export interface ArtifactStore {
271
286
  append(record: DeployRecord): Promise<void>;
@@ -294,14 +309,10 @@ export interface ArtifactStore {
294
309
  readonly automationId?: string;
295
310
  }): Promise<DeploymentActivationResult | undefined>;
296
311
  /**
297
- * Compare and set the active record's access mode. The precondition is the raw persisted value:
298
- * legacy `undefined` remains distinguishable from an explicitly stored `owner-only`.
312
+ * Compare and set the active record's access mode and effective owner. The access precondition is the raw
313
+ * persisted value; the owner precondition resolves legacy records through creator provenance.
299
314
  */
300
- updateActiveAccess(ref: TenantRef, deploymentId: string, accessMode: AccessMode, precondition: {
301
- readonly expectedAccessMode: AccessMode | undefined;
302
- /** Compiler-authoritative projection persisted atomically when enabling customer access. */
303
- readonly serverAuth?: TenantAuthConfig;
304
- }): Promise<DeployRecord | undefined>;
315
+ updateActiveAccess(ref: TenantRef, deploymentId: string, input: ActiveAccessUpdateInput): Promise<DeployRecord | undefined>;
305
316
  listDeployments(filter: DeploymentListFilter): Promise<readonly DeploymentSummary[]>;
306
317
  /**
307
318
  * App soft delete (ADR 0117). `getAppArchivedAt` reports the app's archived stamp (defined iff the
@@ -20,6 +20,10 @@
20
20
  "./main": {
21
21
  "types": "./dist/main.d.ts",
22
22
  "import": "./dist/main.js"
23
+ },
24
+ "./oauth-google": {
25
+ "types": "./dist/oauth/google.d.ts",
26
+ "import": "./dist/oauth/google.js"
23
27
  }
24
28
  },
25
29
  "bin": {
@@ -35,7 +39,7 @@
35
39
  "dependencies": {
36
40
  "@modelcontextprotocol/sdk": "^1.29.0",
37
41
  "@noodle-borg/admission-limits": "0.0.0",
38
- "@noodle-borg/agent-kit": "0.81.0",
42
+ "@noodle-borg/agent-kit": "0.82.0",
39
43
  "@noodle-borg/app-package": "0.0.0",
40
44
  "@noodle-borg/assistant-gateway": "0.0.0",
41
45
  "@noodle-borg/auth": "0.0.0",