@noodleseed/one 0.167.0 → 0.169.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 (44) hide show
  1. package/dist/commands/deployment-delete-ops.d.ts +12 -0
  2. package/dist/commands/deployment-delete-ops.d.ts.map +1 -0
  3. package/dist/commands/deployment-delete-ops.js +230 -0
  4. package/dist/commands/deployment-delete-ops.js.map +1 -0
  5. package/dist/commands/deployment-delete-recovery.d.ts +35 -0
  6. package/dist/commands/deployment-delete-recovery.d.ts.map +1 -0
  7. package/dist/commands/deployment-delete-recovery.js +99 -0
  8. package/dist/commands/deployment-delete-recovery.js.map +1 -0
  9. package/dist/commands/deployments-ops.d.ts +2 -1
  10. package/dist/commands/deployments-ops.d.ts.map +1 -1
  11. package/dist/commands/deployments-ops.js +5 -1
  12. package/dist/commands/deployments-ops.js.map +1 -1
  13. package/node_modules/@noodle-borg/agent-kit/dist/behavior-skills.js +4 -2
  14. package/node_modules/@noodle-borg/agent-kit/dist/plugin-bootstrap-skill.js +8 -1
  15. package/node_modules/@noodle-borg/agent-kit/dist/skill-embedded-assistant-ref.js +20 -0
  16. package/node_modules/@noodle-borg/agent-kit/dist/skill-router.js +9 -2
  17. package/node_modules/@noodle-borg/agent-kit/package.json +1 -1
  18. package/node_modules/@noodle-borg/cli-catalog/dist/catalog-data-tenant-resources.js +27 -1
  19. package/node_modules/@noodle-borg/control-plane/dist/delete-deployment.d.ts +44 -0
  20. package/node_modules/@noodle-borg/control-plane/dist/delete-deployment.js +105 -0
  21. package/node_modules/@noodle-borg/control-plane/dist/deployment-deletion-contracts.d.ts +20 -0
  22. package/node_modules/@noodle-borg/control-plane/dist/portable.d.ts +2 -0
  23. package/node_modules/@noodle-borg/control-plane/dist/portable.js +1 -0
  24. package/node_modules/@noodle-borg/service/dist/deployment-deletion.js +40 -0
  25. package/node_modules/@noodle-borg/service/dist/registry-compile.js +6 -0
  26. package/node_modules/@noodle-borg/service/dist/registry-deletion.js +75 -0
  27. package/node_modules/@noodle-borg/service/dist/registry-state.js +4 -2
  28. package/node_modules/@noodle-borg/service/dist/registry-targets.js +4 -0
  29. package/node_modules/@noodle-borg/service/dist/registry.d.ts +2 -0
  30. package/node_modules/@noodle-borg/service/dist/registry.js +7 -23
  31. package/node_modules/@noodle-borg/service/dist/routes/deploy-dispatch.js +15 -1
  32. package/node_modules/@noodle-borg/service/dist/routes/deployment-delete.js +53 -0
  33. package/node_modules/@noodle-borg/service/dist/store/in-memory.d.ts +2 -1
  34. package/node_modules/@noodle-borg/service/dist/store/in-memory.js +8 -0
  35. package/node_modules/@noodle-borg/service/dist/store/json-file-deletion.js +64 -0
  36. package/node_modules/@noodle-borg/service/dist/store/json-file.d.ts +2 -1
  37. package/node_modules/@noodle-borg/service/dist/store/json-file.js +23 -5
  38. package/node_modules/@noodle-borg/service/dist/store.d.ts +5 -1
  39. package/node_modules/@noodle-borg/service/package.json +1 -1
  40. package/node_modules/@noodle-borg/wire-contracts/dist/deployment-deletion.d.ts +29 -0
  41. package/node_modules/@noodle-borg/wire-contracts/dist/deployment-deletion.js +33 -0
  42. package/node_modules/@noodle-borg/wire-contracts/dist/index.d.ts +1 -0
  43. package/node_modules/@noodle-borg/wire-contracts/dist/index.js +1 -0
  44. package/package.json +2 -2
@@ -3,7 +3,8 @@
3
3
  // one-level reference tree rendered by skill-content.ts.
4
4
  import { BEHAVIOR_SKILLS } from './behavior-skills.js';
5
5
  export const SKILL_DESCRIPTION = 'Use when building, validating, testing, deploying, or operating a local or hosted Noodle Seed ' +
6
- 'MCP server or app authored in TypeScript with the noodle CLI.';
6
+ 'MCP server or app authored in TypeScript with the noodle CLI, or improving signup or onboarding ' +
7
+ 'conversion or selecting a first Noodle workflow for an account-growth product.';
7
8
  export const APP_DIRECTORY_COMPLIANCE_REFERENCE = 'references/app-directory-compliance.md';
8
9
  export const SKILL_ROUTES = [
9
10
  {
@@ -68,7 +69,7 @@ export const SKILL_ROUTES = [
68
69
  },
69
70
  {
70
71
  id: 'embed-assistant',
71
- intent: 'Embed a Noodle assistant in an existing SaaS or web application',
72
+ intent: 'Embed a Noodle assistant in an existing SaaS or web application, including public-to-product continuous onboarding',
72
73
  skill: 'embedding-mcp-assistants',
73
74
  exitCondition: 'The requested embed boundary works with verified identity and credential separation at the tested level.',
74
75
  },
@@ -147,6 +148,12 @@ export function skillRouterBody(_target) {
147
148
  '',
148
149
  'Negative routing examples: “Inspect hosted logs/status” → `inspect-hosted` (read-only). “Prepare for deployment” → the applicable build or verification route; with existing access it may inspect `deploy preflight`, then stop with a handoff. Preparation does not authorize `link`, hosted config, publication, rollback, host writes, or submission. “Keep this local” → a build or verification route, never a hosted route.',
149
150
  '',
151
+ '## First-workflow heuristic',
152
+ '',
153
+ 'When the user asks what to build first or gives only a broad growth goal, inspect the repository and product context before asking them to invent a workflow. Do not classify the business or choose a design from an industry label alone.',
154
+ '',
155
+ 'If the evidence shows a public visitor surface, an existing signup or sign-in boundary, a useful pre-account result the assistant can genuinely produce, and a meaningful authenticated outcome inside the product—and more authenticated users are valuable—recommend public-to-product continuous onboarding and route to `embedding-mcp-assistants`. This recommendation is not limited to B2B SaaS. If one of those facts is unclear, ask only for the smallest missing fit fact; if the pattern does not fit, route by the user’s actual outcome instead.',
156
+ '',
150
157
  ...routeTableLines(),
151
158
  '',
152
159
  '## Common machine loop',
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@noodle-borg/agent-kit",
3
- "version": "0.102.0",
3
+ "version": "0.104.0",
4
4
  "license": "Apache-2.0",
5
5
  "type": "module",
6
6
  "engines": {
@@ -174,7 +174,7 @@ export const CATALOG_TENANT_RESOURCES = [
174
174
  {
175
175
  name: 'deployments',
176
176
  section: 'resources',
177
- summary: 'List, inspect, or read packages for individual deployments.',
177
+ summary: 'List, inspect, read packages, or permanently delete deployment records.',
178
178
  arguments: [],
179
179
  flags: [],
180
180
  subcommands: [
@@ -203,6 +203,32 @@ export const CATALOG_TENANT_RESOURCES = [
203
203
  flags: DEPLOYMENT_INSPECT_FLAGS,
204
204
  jsonOutput: { mode: 'single' },
205
205
  },
206
+ {
207
+ name: 'delete',
208
+ summary: 'Permanently delete one inactive deployment record.',
209
+ arguments: [
210
+ {
211
+ ...REQUIRED_ARGUMENT,
212
+ name: 'deployment-id',
213
+ summary: 'Inactive deployment identifier to delete permanently.',
214
+ },
215
+ ],
216
+ flags: [ORG, SERVICE, AUTH_TOKEN, YES, JSON_FLAG],
217
+ jsonOutput: { mode: 'single' },
218
+ },
219
+ {
220
+ name: 'delete-version',
221
+ summary: 'Permanently delete every deployment record for one exact version.',
222
+ arguments: [
223
+ {
224
+ ...REQUIRED_ARGUMENT,
225
+ name: 'version-or-legacy',
226
+ summary: 'Exact numeric server version, or legacy for unversioned deployments.',
227
+ },
228
+ ],
229
+ flags: [...DEPLOYMENT_BASE_FLAGS, YES, JSON_FLAG],
230
+ jsonOutput: { mode: 'single' },
231
+ },
206
232
  {
207
233
  name: 'lock',
208
234
  summary: 'Freeze an active server version against deploys and rollbacks.',
@@ -0,0 +1,44 @@
1
+ import type { AuditSink, DeploymentOperationTarget } from '@noodle-borg/module';
2
+ import type { DeploymentDeleteResponse } from '@noodle-borg/wire-contracts';
3
+ import type { ControlPlaneIdentity } from './contracts.js';
4
+ import type { DeploymentDeleteResult, DeploymentDeleteSelection } from './deployment-deletion-contracts.js';
5
+ import type { RollbackControlPlane } from './rollback-deployment.js';
6
+ export type DeploymentDeleteScope = {
7
+ readonly kind: 'deployment';
8
+ readonly org: string;
9
+ readonly deploymentId: string;
10
+ } | {
11
+ readonly kind: 'version';
12
+ readonly org: string;
13
+ readonly target: DeploymentOperationTarget;
14
+ readonly serverVersion?: string;
15
+ };
16
+ export interface DeploymentDeletionRegistry {
17
+ getDeployment(org: string, deploymentId: string): Promise<{
18
+ readonly orgSlug: string;
19
+ readonly appSlug: string;
20
+ readonly environment: string;
21
+ } | undefined>;
22
+ deleteDeployments(target: DeploymentOperationTarget, selection: DeploymentDeleteSelection): Promise<DeploymentDeleteResult>;
23
+ }
24
+ export interface DeploymentDeletionDependencies {
25
+ readonly registry: DeploymentDeletionRegistry;
26
+ readonly controlPlane: RollbackControlPlane;
27
+ readonly audit: AuditSink;
28
+ }
29
+ export type DeploymentDeleteOperationResult = {
30
+ readonly ok: true;
31
+ readonly view: DeploymentDeleteResponse;
32
+ } | {
33
+ readonly ok: false;
34
+ readonly status: 400 | 403 | 404 | 409;
35
+ readonly code: string;
36
+ readonly error: string;
37
+ };
38
+ /** One owner-authorized deletion path for every host; stores commit the exact selection atomically. */
39
+ export declare function deleteDeploymentOperation(deps: DeploymentDeletionDependencies, input: {
40
+ readonly actor: ControlPlaneIdentity;
41
+ readonly scope: DeploymentDeleteScope;
42
+ readonly expectedDeploymentIds?: readonly string[];
43
+ }): Promise<DeploymentDeleteOperationResult>;
44
+ //# sourceMappingURL=delete-deployment.d.ts.map
@@ -0,0 +1,105 @@
1
+ const ERRORS = {
2
+ deployment_not_found: 'Deployment not found.',
3
+ version_not_found: 'Version not found.',
4
+ active_deployment: 'Roll back to another deployment before deleting the live deployment, or delete the whole version.',
5
+ deployment_locked: 'Unlock this version before deleting it.',
6
+ app_archived: 'Restore the archived app before deleting its deployments.',
7
+ deployment_delete_conflict: 'The deployment history changed. Refresh and review it before deleting.',
8
+ };
9
+ /** One owner-authorized deletion path for every host; stores commit the exact selection atomically. */
10
+ export async function deleteDeploymentOperation(deps, input) {
11
+ const { actor: identity, scope: route } = input;
12
+ const org = route.kind === 'version' ? route.target.org : route.org;
13
+ const member = identity.superAdmin
14
+ ? undefined
15
+ : await deps.controlPlane.getOrgMember({ org, subject: identity.subject });
16
+ if (identity.developerGrantId !== undefined ||
17
+ (!identity.superAdmin && member?.role !== 'owner')) {
18
+ return {
19
+ ok: false,
20
+ status: 403,
21
+ code: 'organization_owner_required',
22
+ error: 'Only an organization owner can delete deployments or versions.',
23
+ };
24
+ }
25
+ let target;
26
+ let selection;
27
+ if (route.kind === 'deployment') {
28
+ const record = await deps.registry.getDeployment(route.org, route.deploymentId);
29
+ if (!record)
30
+ return {
31
+ ok: false,
32
+ status: 404,
33
+ code: 'deployment_not_found',
34
+ error: ERRORS.deployment_not_found,
35
+ };
36
+ target = { org: record.orgSlug, app: record.appSlug, env: record.environment };
37
+ selection = { kind: 'deployment', deploymentId: route.deploymentId };
38
+ }
39
+ else {
40
+ if (!input.expectedDeploymentIds?.length)
41
+ return {
42
+ ok: false,
43
+ status: 400,
44
+ code: 'invalid_deletion_confirmation',
45
+ error: 'Invalid version deletion confirmation.',
46
+ };
47
+ target = route.target;
48
+ selection = {
49
+ kind: 'version',
50
+ expectedDeploymentIds: input.expectedDeploymentIds,
51
+ ...(route.serverVersion === undefined ? {} : { serverVersion: route.serverVersion }),
52
+ };
53
+ }
54
+ const result = await deps.registry.deleteDeployments(target, selection);
55
+ const status = result.ok
56
+ ? 200
57
+ : result.code === 'deployment_not_found' || result.code === 'version_not_found'
58
+ ? 404
59
+ : 409;
60
+ let auditRecorded = true;
61
+ try {
62
+ await deps.audit.emit({
63
+ eventType: result.ok
64
+ ? route.kind === 'version'
65
+ ? 'deployment.version.deleted'
66
+ : 'deployment.deleted'
67
+ : 'deployment.delete.rejected',
68
+ org: target.org,
69
+ app: target.app,
70
+ env: target.env,
71
+ ...(route.kind === 'deployment' ? { deploymentId: route.deploymentId } : {}),
72
+ actorSubject: identity.subject,
73
+ ...(identity.email === undefined ? {} : { actorEmail: identity.email }),
74
+ decision: result.ok ? 'allow' : 'deny',
75
+ status,
76
+ ...(!result.ok ? { reasonCode: result.code } : {}),
77
+ details: {
78
+ scope: route.kind,
79
+ ...(route.kind === 'version' ? { serverVersion: route.serverVersion ?? 'legacy' } : {}),
80
+ ...(result.ok ? { deletedDeployments: result.deleted.length } : {}),
81
+ },
82
+ });
83
+ }
84
+ catch {
85
+ // Deletion has already committed. An audit outage must not claim the records still exist.
86
+ auditRecorded = false;
87
+ }
88
+ if (!result.ok)
89
+ return {
90
+ ok: false,
91
+ status: status,
92
+ code: result.code,
93
+ error: ERRORS[result.code],
94
+ };
95
+ return {
96
+ ok: true,
97
+ view: {
98
+ ok: true,
99
+ target,
100
+ deletedDeploymentIds: result.deleted.map((record) => record.deploymentId),
101
+ auditRecorded,
102
+ },
103
+ };
104
+ }
105
+ //# sourceMappingURL=delete-deployment.js.map
@@ -0,0 +1,20 @@
1
+ export type DeploymentDeleteSelection = {
2
+ readonly kind: 'deployment';
3
+ readonly deploymentId: string;
4
+ } | {
5
+ readonly kind: 'version';
6
+ readonly serverVersion?: string;
7
+ readonly expectedDeploymentIds: readonly string[];
8
+ };
9
+ export type DeploymentDeleteResult<Record extends {
10
+ readonly deploymentId: string;
11
+ } = {
12
+ readonly deploymentId: string;
13
+ }> = {
14
+ readonly ok: true;
15
+ readonly deleted: readonly Record[];
16
+ } | {
17
+ readonly ok: false;
18
+ readonly code: 'deployment_not_found' | 'version_not_found' | 'active_deployment' | 'deployment_locked' | 'app_archived' | 'deployment_delete_conflict';
19
+ };
20
+ //# sourceMappingURL=deployment-deletion-contracts.d.ts.map
@@ -1,8 +1,10 @@
1
1
  export { type AppPurgeReconciliationActor, type AppPurgeReconciliationConflictCode, AppPurgeReconciliationError, type AppPurgeReconciliationOperator, } from './app-purge-reconciliation-port.js';
2
2
  export type { ActiveMcpSubdomainClaim, ChangeMcpSubdomainInput, ControlPlaneIdentity, CreateOrgWithOwnerInput, McpSubdomainMutationResult, McpSubdomainSetting, OrganizationStore, OrgDomainRecord, OrgInvitationRecord, OrgMemberRecord, OrgOpenAIAppsChallengeRecord, OrgRecord, OrgRole, PersonalWorkspaceProvisionInput, PersonalWorkspaceProvisionResult, SignupAllowlistKind, SignupAllowlistRecord, WelcomeEmailRecord, } from './contracts.js';
3
3
  export { PersonalWorkspaceOwnerMutationError } from './contracts.js';
4
+ export { type DeploymentDeleteOperationResult, type DeploymentDeleteScope, type DeploymentDeletionDependencies, type DeploymentDeletionRegistry, deleteDeploymentOperation, } from './delete-deployment.js';
4
5
  export { allowAllGate, bearerToken, type ControlPlaneAuthResult, type DeployAuthGate, } from './deploy-auth.js';
5
6
  export { CompositeControlPlaneGate, type ControlPlaneSignupMode, GoogleControlPlaneGate, type GoogleControlPlaneGateOptions, type GoogleIdTokenVerifier, GoogleWorkloadControlPlaneGate, type GoogleWorkloadControlPlaneGateOptions, NoodleOAuthControlPlaneGate, type NoodleOAuthControlPlaneGateOptions, type SignupAuthorizer, } from './deploy-gates.js';
7
+ export type { DeploymentDeleteResult, DeploymentDeleteSelection, } from './deployment-deletion-contracts.js';
6
8
  export { InMemoryControlPlaneStore } from './in-memory-control-plane-store.js';
7
9
  export { bindInMemoryOrganizationStore, type InMemoryOrganizationOperations, InMemoryOrganizationStore, } from './in-memory-organization-store.js';
8
10
  export { InMemoryMcpSubdomainClaimStore, McpSubdomainCooldownError, McpSubdomainIdempotencyConflictError, McpSubdomainOwnerRequiredError, type McpSubdomainRouteRef, McpSubdomainUnavailableError, mcpSubdomainEndpointOptions, type ResolvedMcpTenantRef, resolveMcpSubdomainTenant, } from './mcp-subdomain-claims.js';
@@ -1,5 +1,6 @@
1
1
  export { AppPurgeReconciliationError, } from './app-purge-reconciliation-port.js';
2
2
  export { PersonalWorkspaceOwnerMutationError } from './contracts.js';
3
+ export { deleteDeploymentOperation, } from './delete-deployment.js';
3
4
  export { allowAllGate, bearerToken, } from './deploy-auth.js';
4
5
  export { CompositeControlPlaneGate, GoogleControlPlaneGate, GoogleWorkloadControlPlaneGate, NoodleOAuthControlPlaneGate, } from './deploy-gates.js';
5
6
  export { InMemoryControlPlaneStore } from './in-memory-control-plane-store.js';
@@ -0,0 +1,40 @@
1
+ import { normalizeServerVersion } from '@noodle-borg/module';
2
+ import { sameTenantRecord } from './deployment-versioning.js';
3
+ import { validateTenantRef } from './store/validate.js';
4
+ /** Shared, side-effect-free decision made inside each adapter's mutation boundary. */
5
+ export function planDeploymentDeletion(records, ref, selection) {
6
+ const safe = validateTenantRef(ref);
7
+ const version = selection.kind === 'version' && selection.serverVersion !== undefined
8
+ ? normalizeServerVersion(selection.serverVersion)
9
+ : undefined;
10
+ const deleted = records.filter((record) => sameTenantRecord(record, safe) &&
11
+ (selection.kind === 'deployment'
12
+ ? record.deploymentId === selection.deploymentId
13
+ : record.serverVersion === version));
14
+ if (deleted.length === 0)
15
+ return {
16
+ ok: false,
17
+ code: selection.kind === 'deployment' ? 'deployment_not_found' : 'version_not_found',
18
+ };
19
+ if (records.some((record) => record.orgSlug === safe.org &&
20
+ record.appSlug === safe.app &&
21
+ record.archivedAt !== undefined)) {
22
+ return { ok: false, code: 'app_archived' };
23
+ }
24
+ if (selection.kind === 'deployment') {
25
+ if (deleted.some((record) => record.active))
26
+ return { ok: false, code: 'active_deployment' };
27
+ }
28
+ else {
29
+ if (deleted.some((record) => record.active && record.deploymentLock !== undefined))
30
+ return { ok: false, code: 'deployment_locked' };
31
+ const expected = new Set(selection.expectedDeploymentIds);
32
+ if (expected.size !== selection.expectedDeploymentIds.length ||
33
+ expected.size !== deleted.length ||
34
+ deleted.some((record) => !expected.has(record.deploymentId))) {
35
+ return { ok: false, code: 'deployment_delete_conflict' };
36
+ }
37
+ }
38
+ return { ok: true, deleted };
39
+ }
40
+ //# sourceMappingURL=deployment-deletion.js.map
@@ -9,6 +9,7 @@ import { ManagedConfigBroker } from './credential-broker.js';
9
9
  import { deploymentCredentialBrokerOptions } from './credential-broker-options.js';
10
10
  import { deploymentRecordVersionError } from './deployment-record-version.js';
11
11
  import { normalizePersistedManifestForCompile } from './manifest-normalize.js';
12
+ import { registryRecordStillExists } from './registry-deletion.js';
12
13
  import { missingCapabilityErrors, missingSecretErrors, missingVariableErrors, } from './registry-helpers.js';
13
14
  import { servedTargetFor } from './registry-targets.js';
14
15
  import { createDeploymentStateConnector, } from './state-connector-factory.js';
@@ -252,6 +253,11 @@ export async function loadPersistedRegistryTarget(source, context) {
252
253
  if (record.active &&
253
254
  (await context.hasCustomerAuthConflict(record, built.served.artifact.server.auth)))
254
255
  return undefined;
256
+ const exists = state.store === undefined
257
+ ? state.records.has(deploymentId)
258
+ : await registryRecordStillExists(state, deploymentId);
259
+ if (!exists)
260
+ return undefined;
255
261
  const target = servedTargetFor(record, built.served, context.customerVerifierFactory);
256
262
  state.servers.set(deploymentId, target);
257
263
  state.records.set(deploymentId, record);
@@ -0,0 +1,75 @@
1
+ import { planDeploymentDeletion } from './deployment-deletion.js';
2
+ import { targetForPersistedRecord } from './registry-targets.js';
3
+ import { validateTenantRef } from './store/validate.js';
4
+ export function evictDeletedDeployments(state, ids) {
5
+ for (const id of ids) {
6
+ state.records.delete(id);
7
+ state.servers.delete(id);
8
+ }
9
+ for (const [key, id] of state.activeTenants)
10
+ if (ids.has(id))
11
+ state.activeTenants.delete(key);
12
+ }
13
+ export async function deleteRegistryDeployments(state, ref, selection) {
14
+ const safe = validateTenantRef(ref);
15
+ const result = state.store === undefined
16
+ ? planDeploymentDeletion([...state.records.values()], safe, selection)
17
+ : await state.store.deleteDeployments(safe, selection);
18
+ if (result.ok)
19
+ evictDeletedDeployments(state, new Set(result.deleted.map((record) => record.deploymentId)));
20
+ return result;
21
+ }
22
+ /** Async compilation must rejoin authority before publishing a result into the disposable cache. */
23
+ export async function registryRecordStillExists(state, deploymentId) {
24
+ const current = state.store === undefined
25
+ ? state.records.get(deploymentId)
26
+ : await state.store.get(deploymentId);
27
+ if (current !== undefined && current.archivedAt === undefined)
28
+ return true;
29
+ evictDeletedDeployments(state, new Set([deploymentId]));
30
+ return false;
31
+ }
32
+ /** Refresh warmed cache entries from authority and coalesce compilation on a cache miss. */
33
+ export async function readRegistryTarget(state, deploymentId, inflight, targetForRecord, load) {
34
+ if (state.records.get(deploymentId)?.archivedAt !== undefined)
35
+ return undefined;
36
+ if (state.servers.has(deploymentId)) {
37
+ const record = state.store
38
+ ? await state.store.get(deploymentId)
39
+ : state.records.get(deploymentId);
40
+ if (record === undefined)
41
+ evictDeletedDeployments(state, new Set([deploymentId]));
42
+ return record === undefined || record.archivedAt !== undefined
43
+ ? undefined
44
+ : targetForRecord(record);
45
+ }
46
+ const pending = inflight.get(deploymentId);
47
+ if (pending)
48
+ return pending;
49
+ const promise = load(deploymentId).finally(() => {
50
+ inflight.delete(deploymentId);
51
+ });
52
+ inflight.set(deploymentId, promise);
53
+ return promise;
54
+ }
55
+ /** No-store records are authority, so stale reconciliation must never repopulate absent records. */
56
+ export async function reconcileRegistryTarget(state, record, context) {
57
+ let loaded = false;
58
+ const target = await targetForPersistedRecord(record, {
59
+ ...context,
60
+ load: () => {
61
+ loaded = true;
62
+ return context.load();
63
+ },
64
+ records: state.records,
65
+ servers: state.servers,
66
+ recordStillPresent: () => state.store !== undefined || state.records.has(record.deploymentId),
67
+ });
68
+ // A cold load already checks store authority just before publication; warm reconciliation must too.
69
+ // No async presence snapshot in no-store mode: deletion and publication share one synchronous turn.
70
+ const exists = state.store === undefined
71
+ ? state.records.has(record.deploymentId)
72
+ : loaded || (await registryRecordStillExists(state, record.deploymentId));
73
+ return exists ? target : undefined;
74
+ }
75
+ //# sourceMappingURL=registry-deletion.js.map
@@ -166,8 +166,10 @@ export async function setRegistryDeploymentLock(state, ref, serverVersion, expec
166
166
  state.records.set(result.record.deploymentId, result.record);
167
167
  return result;
168
168
  }
169
- const active = await activeRecordVersion(state, safe, safeVersion);
170
- if (active === undefined)
169
+ const observed = await activeRecordVersion(state, safe, safeVersion);
170
+ // The lookup yields; a concurrent deletion or pointer move must win before any cache write.
171
+ const active = observed === undefined ? undefined : state.records.get(observed.deploymentId);
172
+ if (active === undefined || !active.active || active.archivedAt !== undefined)
171
173
  return { ok: false, reason: 'no_active_deployment' };
172
174
  if (active.deploymentId !== expectedDeploymentId)
173
175
  return { ok: false, reason: 'conflict' };
@@ -110,6 +110,8 @@ function bindAppPackageSnapshot(served, value) {
110
110
  }
111
111
  /** Reconcile one persisted record with its cached served target without trusting stale auth projections. */
112
112
  export async function targetForPersistedRecord(record, state) {
113
+ if (state.recordStillPresent?.() === false)
114
+ return undefined;
113
115
  const cached = state.servers.get(record.deploymentId);
114
116
  const cachedSchemaVersion = cached === undefined ? undefined : targetPolicyVersions.get(cached);
115
117
  state.records.set(record.deploymentId, record);
@@ -127,6 +129,8 @@ export async function targetForPersistedRecord(record, state) {
127
129
  (await state.hasCustomerAuthConflict(record, cached.served.artifact.server.auth))) {
128
130
  return undefined;
129
131
  }
132
+ if (state.recordStillPresent?.() === false)
133
+ return undefined;
130
134
  if (cachedSchemaVersion === record.schemaVersion && servedTargetMatchesRecord(cached, record))
131
135
  return cached;
132
136
  const reconciled = servedTargetFor(record, cached.served, state.customerVerifierFactory);
@@ -9,6 +9,7 @@ import type { ServedTarget } from '@noodle-borg/transport-http';
9
9
  import type { NativeRecordConnectorFactory } from './native-record-connector.js';
10
10
  import { type ActiveDeployProvenance } from './registry-helpers.js';
11
11
  import type { AccessUpdateOptions, AccessUpdateResult, DeployOptions, DeployPreflightResult, RecoverResult, RunDeployResult, ServerRegistryOptions } from './registry-types.js';
12
+ import type { DeploymentDeleteResult, DeploymentDeleteSelection } from './store.js';
12
13
  import { type AppArchiveResult, type AppRestoreResult, type AppSummary, type ArtifactStore, type ConfigStore, type CustomerAuthRestoreProjection, type DeploymentListFilter, type DeploymentLock, type DeploymentLockUpdateResult, type DeploymentStatus, type DeploymentSummary, type DeployRecord, type EnvSummary, type ProductionEnvironmentChange, type TenantRef } from './store.js';
13
14
  export type { RollbackResult } from '@noodle-borg/control-plane/portable';
14
15
  export type { DeploymentPackage } from './registry-package.js';
@@ -29,6 +30,7 @@ export declare class ServerRegistry {
29
30
  /** Read-only deploy validation used by the CLI before config writes, asset upload, or persistence. */
30
31
  preflightDeploy(tenant: TenantRef, manifest: string, options?: DeployOptions): Promise<DeployPreflightResult>;
31
32
  recover(): Promise<RecoverResult>;
33
+ deleteDeployments(ref: TenantRef, selection: DeploymentDeleteSelection): Promise<DeploymentDeleteResult>;
32
34
  get(deploymentId: string): Promise<ServedTarget | undefined>;
33
35
  /** Resolve a deployment-id data-plane route only while that exact record is actively serving. */
34
36
  getServing(deploymentId: string): Promise<ServedTarget | undefined>;
@@ -6,6 +6,7 @@ import { DeploymentPolicyChangedError, UnsupportedDeploymentRecordVersionError,
6
6
  import { defaultActiveRecord } from './deployment-versioning.js';
7
7
  import { updateRegistryAccess } from './registry-access.js';
8
8
  import { compilePersistedRegistryRecord, compileRegistryTarget, loadPersistedRegistryTarget, } from './registry-compile.js';
9
+ import { deleteRegistryDeployments, readRegistryTarget, reconcileRegistryTarget, } from './registry-deletion.js';
9
10
  import { createRegistryDeployRecord, deployedRecordResult, deploymentWriteVersionError, preflightRegistryDeploy, replacementDeploymentPolicy, resolveDeployAttempt, withDeploymentConfiguration, } from './registry-deploy-transaction.js';
10
11
  import { deploymentOwnerSubject } from './registry-helpers.js';
11
12
  import { registryDeploymentPackage, renderDeploymentPackageSnapshot } from './registry-package.js';
@@ -13,7 +14,7 @@ import { recoverRegistryRecords } from './registry-recover.js';
13
14
  import { rollbackDeployment, rollbackWithMappedErrors } from './registry-rollback.js';
14
15
  import { activeRecord, activeRecordVersion, persistDeployRecord, reconcilePlatformAccountResetRegistryCache, registryAppArchivedAt, registryArchiveApp, registryCustomerAuthRestoreProjections, registryGetApp, registryGetDeployment, registryGetEnvironment, registryListApps, registryListEnvironments, registryRestoreApp, registrySetProductionEnvironment, registrySweepArchived, setRegistryDeploymentLock, } from './registry-state.js';
15
16
  import { deploymentStatusFor } from './registry-status.js';
16
- import { deploymentSourceFor, rebindNativeRecords, servedTargetFor, targetForPersistedRecord, tenantDeploymentKey, tenantKey, } from './registry-targets.js';
17
+ import { deploymentSourceFor, rebindNativeRecords, servedTargetFor, tenantDeploymentKey, tenantKey, } from './registry-targets.js';
17
18
  import { withBuiltinStateCatalog } from './state-catalog.js';
18
19
  import { deploymentSummary, matchesDeploymentFilter, validateDeploymentListFilter, } from './store/records.js';
19
20
  import { InMemoryConfigStore, validateTenantRef, } from './store.js';
@@ -318,26 +319,11 @@ export class ServerRegistry {
318
319
  #compilePersistedRecord(record) {
319
320
  return compilePersistedRegistryRecord(record, this.#compileTarget.bind(this));
320
321
  }
322
+ deleteDeployments(ref, selection) {
323
+ return deleteRegistryDeployments(this.#stateView(), ref, selection);
324
+ }
321
325
  async get(deploymentId) {
322
- if (this.#records.get(deploymentId)?.archivedAt !== undefined)
323
- return undefined;
324
- const cached = this.#servers.get(deploymentId);
325
- if (cached) {
326
- const record = this.#store
327
- ? await this.#store.get(deploymentId)
328
- : this.#records.get(deploymentId);
329
- return record === undefined || record.archivedAt !== undefined
330
- ? undefined
331
- : this.#targetForPersistedRecord(record);
332
- }
333
- const inflight = this.#inflight.get(deploymentId);
334
- if (inflight)
335
- return inflight;
336
- const promise = this.#loadAndCompile(deploymentId).finally(() => {
337
- this.#inflight.delete(deploymentId);
338
- });
339
- this.#inflight.set(deploymentId, promise);
340
- return promise;
326
+ return readRegistryTarget(this.#stateView(), deploymentId, this.#inflight, (record) => this.#targetForPersistedRecord(record), (id) => this.#loadAndCompile(id));
341
327
  }
342
328
  /** Resolve a deployment-id data-plane route only while that exact record is actively serving. */
343
329
  async getServing(deploymentId) {
@@ -499,9 +485,7 @@ export class ServerRegistry {
499
485
  return this.#knowledgeSearch;
500
486
  }
501
487
  async #targetForPersistedRecord(record) {
502
- return targetForPersistedRecord(record, {
503
- records: this.#records,
504
- servers: this.#servers,
488
+ return reconcileRegistryTarget(this.#stateView(), record, {
505
489
  ...(this.#customerVerifierFactory
506
490
  ? { customerVerifierFactory: this.#customerVerifierFactory }
507
491
  : {}),
@@ -2,9 +2,23 @@ import { applySecurityHeaders, enforceHttps, } from '@noodle-borg/transport-http
2
2
  import { respondRouteError } from '../http-util.js';
3
3
  import { handleDeploy } from './control-plane.js';
4
4
  import { handleDeployPreflight } from './deploy-preflight.js';
5
+ import { handleDeploymentDelete, parseVersionDeletePath } from './deployment-delete.js';
5
6
  import { handleDeploymentLockUpdate } from './deployment-lock.js';
6
- import { parseTenantDeploymentLockPath, parseTenantDeployPath, parseTenantDeployPreflightPath, } from './paths.js';
7
+ import { parseDeploymentItemPath, parseTenantDeploymentLockPath, parseTenantDeployPath, parseTenantDeployPreflightPath, } from './paths.js';
7
8
  export function dispatchDeployRoutes(req, res, url, deps) {
9
+ if (req.method === 'DELETE') {
10
+ const item = parseDeploymentItemPath(url.pathname);
11
+ const deletion = item
12
+ ? { kind: 'deployment', ...item }
13
+ : parseVersionDeletePath(url.pathname);
14
+ if (deletion) {
15
+ applySecurityHeaders(res, deps.tls);
16
+ if (enforceHttps(req, res, deps.tls))
17
+ return true;
18
+ handleDeploymentDelete(req, res, deletion, { ...deps, maxBody: deps.maxBody.control }).catch((error) => respondRouteError(deps.logger, res, 'deployment-delete.error', error));
19
+ return true;
20
+ }
21
+ }
8
22
  const deploymentLock = parseTenantDeploymentLockPath(url.pathname);
9
23
  if (req.method === 'PATCH' && deploymentLock !== undefined) {
10
24
  applySecurityHeaders(res, deps.tls);
@@ -0,0 +1,53 @@
1
+ import { deleteDeploymentOperation, } from '@noodle-borg/control-plane/portable';
2
+ import { normalizeServerVersion } from '@noodle-borg/module';
3
+ import { readJsonBody, sendJson } from '@noodle-borg/transport-http';
4
+ import { deploymentDeleteResponseSchema, deploymentVersionDeleteRequestSchema, } from '@noodle-borg/wire-contracts';
5
+ import { validateTenantRef } from '../store.js';
6
+ import { authorizeControlPlane } from './control-plane.js';
7
+ /** An exact numeric version, or the legacy unversioned scope; never semantic-version equivalence. */
8
+ export function parseVersionDeletePath(pathname) {
9
+ const match = /^\/v1\/orgs\/([^/]+)\/apps\/([^/]+)\/envs\/([^/]+)\/versions\/([^/]+)$/.exec(pathname);
10
+ if (!match)
11
+ return undefined;
12
+ try {
13
+ const target = validateTenantRef({
14
+ org: decodeURIComponent(match[1]),
15
+ app: decodeURIComponent(match[2]),
16
+ env: decodeURIComponent(match[3]),
17
+ });
18
+ const version = decodeURIComponent(match[4]);
19
+ return {
20
+ kind: 'version',
21
+ org: target.org,
22
+ target,
23
+ ...(version === 'legacy' ? {} : { serverVersion: normalizeServerVersion(version) }),
24
+ };
25
+ }
26
+ catch {
27
+ return undefined;
28
+ }
29
+ }
30
+ export async function handleDeploymentDelete(req, res, route, deps) {
31
+ const identity = await authorizeControlPlane(req, res, deps.gate, { requireIdentity: true });
32
+ if (identity === false)
33
+ return;
34
+ let expectedDeploymentIds;
35
+ if (route.kind === 'version') {
36
+ const body = await readJsonBody(req, deps.maxBody);
37
+ if (!body.ok)
38
+ return sendJson(res, body.status, { error: body.error });
39
+ const parsed = deploymentVersionDeleteRequestSchema.safeParse(body.value);
40
+ if (!parsed.success)
41
+ return sendJson(res, 400, { error: 'Invalid version deletion confirmation.' });
42
+ expectedDeploymentIds = parsed.data.expectedDeploymentIds;
43
+ }
44
+ const result = await deleteDeploymentOperation(deps, {
45
+ actor: identity,
46
+ scope: route,
47
+ ...(expectedDeploymentIds === undefined ? {} : { expectedDeploymentIds }),
48
+ });
49
+ return result.ok
50
+ ? sendJson(res, 200, deploymentDeleteResponseSchema.parse(result.view))
51
+ : sendJson(res, result.status, { code: result.code, error: result.error });
52
+ }
53
+ //# sourceMappingURL=deployment-delete.js.map
@@ -1,7 +1,8 @@
1
- import type { ActiveAccessUpdateInput, AppArchiveResult, AppRestorePrecondition, AppRestoreResult, AppSummary, ArtifactStore, DeploymentActivationPrecondition, DeploymentActivationResult, DeploymentListFilter, DeploymentLock, DeploymentLockUpdateResult, DeploymentPolicyPrecondition, DeploymentSummary, DeployRecord, EnvSummary, ProductionEnvironmentChange, TenantAuthConfig, TenantRef } from '../store.js';
1
+ import type { ActiveAccessUpdateInput, AppArchiveResult, AppRestorePrecondition, AppRestoreResult, AppSummary, ArtifactStore, DeploymentActivationPrecondition, DeploymentActivationResult, DeploymentDeleteResult, DeploymentDeleteSelection, DeploymentListFilter, DeploymentLock, DeploymentLockUpdateResult, DeploymentPolicyPrecondition, DeploymentSummary, DeployRecord, EnvSummary, ProductionEnvironmentChange, TenantAuthConfig, TenantRef } from '../store.js';
2
2
  /** In-memory store: keeps records in a Map. Used by tests and as an explicit, non-persistent option. */
3
3
  export declare class InMemoryArtifactStore implements ArtifactStore {
4
4
  #private;
5
+ deleteDeployments(ref: TenantRef, selection: DeploymentDeleteSelection): Promise<DeploymentDeleteResult>;
5
6
  append(record: DeployRecord, precondition?: DeploymentPolicyPrecondition): Promise<void>;
6
7
  loadAll(): Promise<readonly DeployRecord[]>;
7
8
  get(deploymentId: string): Promise<DeployRecord | undefined>;
@@ -2,6 +2,7 @@ import { normalizeServerVersion } from '@noodle-borg/module';
2
2
  import { assertSameAppPackageSnapshot, sanitizeDeployRecordAppPackageSnapshot, } from '../app-package-snapshot.js';
3
3
  import { assertCustomerAuthRestorePrecondition, assertUniqueActiveCustomerAuthAudienceBindings, findActiveCustomerAuthAudienceConflict, requiresCustomerAuthProjection, } from '../customer-auth-audience-binding.js';
4
4
  import { matchesDeploymentActivation } from '../deployment-activation-precondition.js';
5
+ import { planDeploymentDeletion } from '../deployment-deletion.js';
5
6
  import { assertDeploymentActivationUnlocked, assertDeploymentAppendUnlocked, } from '../deployment-lock.js';
6
7
  import { assertDeploymentAppendPolicy, assertDeploymentAppendVersion, } from '../deployment-record-version.js';
7
8
  import { defaultActiveRecord, sameDeploymentScope, sameTenantRecord, } from '../deployment-versioning.js';
@@ -11,6 +12,13 @@ import { validateSlug, validateTenantRef } from './validate.js';
11
12
  export class InMemoryArtifactStore {
12
13
  #records = new Map();
13
14
  #productionEnvironments = new Map();
15
+ deleteDeployments(ref, selection) {
16
+ const result = planDeploymentDeletion([...this.#records.values()], ref, selection);
17
+ if (result.ok)
18
+ for (const record of result.deleted)
19
+ this.#records.delete(record.deploymentId);
20
+ return Promise.resolve(result);
21
+ }
14
22
  async append(record, precondition) {
15
23
  assertDeploymentAppendVersion([...this.#records.values()], record);
16
24
  assertDeploymentAppendPolicy([...this.#records.values()], record, precondition);