@stigmer/server 3.38.3 → 3.39.1-dev.20260928194002
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/domain/agent/controller.d.ts +2 -2
- package/dist/domain/agent/controller.d.ts.map +1 -1
- package/dist/domain/agent/controller.js +7 -3
- package/dist/domain/agent/controller.js.map +1 -1
- package/dist/domain/agentchannel/controller.d.ts +2 -2
- package/dist/domain/agentchannel/controller.d.ts.map +1 -1
- package/dist/domain/agentchannel/controller.js +10 -5
- package/dist/domain/agentchannel/controller.js.map +1 -1
- package/dist/domain/agentinstance/controller.d.ts +2 -2
- package/dist/domain/agentinstance/controller.d.ts.map +1 -1
- package/dist/domain/agentinstance/controller.js +7 -3
- package/dist/domain/agentinstance/controller.js.map +1 -1
- package/dist/domain/agentinstance/steps.d.ts +3 -1
- package/dist/domain/agentinstance/steps.d.ts.map +1 -1
- package/dist/domain/agentinstance/steps.js +12 -6
- package/dist/domain/agentinstance/steps.js.map +1 -1
- package/dist/domain/agentshare/controller.d.ts +2 -2
- package/dist/domain/agentshare/controller.d.ts.map +1 -1
- package/dist/domain/agentshare/controller.js +7 -3
- package/dist/domain/agentshare/controller.js.map +1 -1
- package/dist/domain/organization/controller.d.ts.map +1 -1
- package/dist/domain/organization/controller.js +33 -14
- package/dist/domain/organization/controller.js.map +1 -1
- package/dist/domain/organization/slug-ledger.d.ts +86 -0
- package/dist/domain/organization/slug-ledger.d.ts.map +1 -0
- package/dist/domain/organization/slug-ledger.js +128 -0
- package/dist/domain/organization/slug-ledger.js.map +1 -0
- package/dist/domain/organization/steps.d.ts +12 -3
- package/dist/domain/organization/steps.d.ts.map +1 -1
- package/dist/domain/organization/steps.js +28 -6
- package/dist/domain/organization/steps.js.map +1 -1
- package/dist/domain/plugin/controller.d.ts.map +1 -1
- package/dist/domain/plugin/controller.js +10 -5
- package/dist/domain/plugin/controller.js.map +1 -1
- package/dist/domain/skill/controller.d.ts.map +1 -1
- package/dist/domain/skill/controller.js +7 -3
- package/dist/domain/skill/controller.js.map +1 -1
- package/dist/domain/workflow/controller.js +6 -3
- package/dist/domain/workflow/controller.js.map +1 -1
- package/dist/domain/workflowinstance/controller.d.ts +2 -2
- package/dist/domain/workflowinstance/controller.d.ts.map +1 -1
- package/dist/domain/workflowinstance/controller.js +10 -5
- package/dist/domain/workflowinstance/controller.js.map +1 -1
- package/dist/domain/workflowinstance/steps.d.ts +5 -3
- package/dist/domain/workflowinstance/steps.d.ts.map +1 -1
- package/dist/domain/workflowinstance/steps.js +14 -9
- package/dist/domain/workflowinstance/steps.js.map +1 -1
- package/dist/extensions/gate-slots.d.ts +7 -5
- package/dist/extensions/gate-slots.d.ts.map +1 -1
- package/dist/extensions/gate-slots.js.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/pipeline/errors.d.ts +8 -0
- package/dist/pipeline/errors.d.ts.map +1 -1
- package/dist/pipeline/errors.js +19 -0
- package/dist/pipeline/errors.js.map +1 -1
- package/dist/store/interface.d.ts +67 -0
- package/dist/store/interface.d.ts.map +1 -1
- package/dist/store/interface.js.map +1 -1
- package/dist/store/organization-slug-history.d.ts +71 -0
- package/dist/store/organization-slug-history.d.ts.map +1 -0
- package/dist/store/organization-slug-history.js +88 -0
- package/dist/store/organization-slug-history.js.map +1 -0
- package/dist/store/postgres/migrations.d.ts +3 -1
- package/dist/store/postgres/migrations.d.ts.map +1 -1
- package/dist/store/postgres/migrations.js +69 -1
- package/dist/store/postgres/migrations.js.map +1 -1
- package/dist/store/postgres/store.d.ts +2 -1
- package/dist/store/postgres/store.d.ts.map +1 -1
- package/dist/store/postgres/store.js +49 -0
- package/dist/store/postgres/store.js.map +1 -1
- package/dist/store/sqlite/migrations.d.ts +3 -1
- package/dist/store/sqlite/migrations.d.ts.map +1 -1
- package/dist/store/sqlite/migrations.js +54 -1
- package/dist/store/sqlite/migrations.js.map +1 -1
- package/dist/store/sqlite/store.d.ts +2 -1
- package/dist/store/sqlite/store.d.ts.map +1 -1
- package/dist/store/sqlite/store.js +55 -0
- package/dist/store/sqlite/store.js.map +1 -1
- package/package.json +6 -6
- package/src/domain/agent/__tests__/store-faults.test.ts +105 -0
- package/src/domain/agent/controller.ts +9 -5
- package/src/domain/agentchannel/__tests__/store-faults.test.ts +126 -0
- package/src/domain/agentchannel/controller.ts +12 -7
- package/src/domain/agentinstance/__tests__/store-faults.test.ts +161 -0
- package/src/domain/agentinstance/controller.ts +9 -5
- package/src/domain/agentinstance/steps.ts +12 -6
- package/src/domain/agentshare/__tests__/store-faults.test.ts +101 -0
- package/src/domain/agentshare/controller.ts +9 -5
- package/src/domain/organization/__tests__/organization-delete.test.ts +73 -18
- package/src/domain/organization/__tests__/organization-slugs.test.ts +386 -0
- package/src/domain/organization/controller.ts +36 -14
- package/src/domain/organization/slug-ledger.ts +209 -0
- package/src/domain/organization/steps.ts +28 -6
- package/src/domain/plugin/__tests__/store-faults.test.ts +104 -0
- package/src/domain/plugin/controller.ts +10 -5
- package/src/domain/skill/__tests__/store-faults.test.ts +103 -0
- package/src/domain/skill/controller.ts +7 -3
- package/src/domain/workflow/__tests__/store-faults.test.ts +107 -0
- package/src/domain/workflow/controller.ts +6 -3
- package/src/domain/workflowinstance/__tests__/store-faults.test.ts +172 -0
- package/src/domain/workflowinstance/controller.ts +12 -7
- package/src/domain/workflowinstance/steps.ts +14 -10
- package/src/extensions/gate-slots.ts +7 -5
- package/src/index.ts +11 -0
- package/src/pipeline/__tests__/blind-not-found.test.ts +4 -24
- package/src/pipeline/errors.ts +23 -0
- package/src/store/__tests__/organization-slug-history.test.ts +109 -0
- package/src/store/__tests__/store-contract.ts +86 -1
- package/src/store/interface.ts +70 -0
- package/src/store/organization-slug-history.ts +159 -0
- package/src/store/postgres/__tests__/migrations.test.ts +138 -2
- package/src/store/postgres/__tests__/store-contract.test.ts +1 -0
- package/src/store/postgres/migrations.ts +84 -1
- package/src/store/postgres/store.ts +84 -0
- package/src/store/sqlite/__tests__/migrations.test.ts +139 -10
- package/src/store/sqlite/migrations.ts +69 -1
- package/src/store/sqlite/store.ts +84 -0
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pins the create side of "an organization's slug is its for good"
|
|
3
|
+
* (slug-ledger.ts) through a composed server in the trusted-local posture:
|
|
4
|
+
*
|
|
5
|
+
* - of two concurrent creates of one slug exactly one succeeds, and the
|
|
6
|
+
* other is refused as a duplicate with one owner row between them;
|
|
7
|
+
* - a create racing another create's claim, before that create's row
|
|
8
|
+
* exists, is refused as a duplicate, never as a deleted organization's;
|
|
9
|
+
* - a pre-side-effect gate's refusal leaves no claim, so the create
|
|
10
|
+
* succeeds once the gate lets it;
|
|
11
|
+
* - a create that fails after its row is stored keeps its slug;
|
|
12
|
+
* - an organization no ledger entry records (one an older binary created)
|
|
13
|
+
* is still refused as a duplicate by its row, and its delete retires
|
|
14
|
+
* the slug.
|
|
15
|
+
*
|
|
16
|
+
* And, over doubles, the release a failed create runs: it frees the claim
|
|
17
|
+
* only when the organization was never stored, and every fault it meets
|
|
18
|
+
* leaves the slug claimed.
|
|
19
|
+
*
|
|
20
|
+
* The delete side (the retire, its order and its faults) is
|
|
21
|
+
* organization-delete.test.ts's.
|
|
22
|
+
*/
|
|
23
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
24
|
+
import { tmpdir } from "node:os";
|
|
25
|
+
import path from "node:path";
|
|
26
|
+
|
|
27
|
+
import { create } from "@bufbuild/protobuf";
|
|
28
|
+
import type { DescMessage } from "@bufbuild/protobuf";
|
|
29
|
+
import { Code, ConnectError, createClient } from "@connectrpc/connect";
|
|
30
|
+
import type { Client } from "@connectrpc/connect";
|
|
31
|
+
import { createGrpcTransport } from "@connectrpc/connect-node";
|
|
32
|
+
import {
|
|
33
|
+
afterAll,
|
|
34
|
+
beforeAll,
|
|
35
|
+
beforeEach,
|
|
36
|
+
describe,
|
|
37
|
+
expect,
|
|
38
|
+
it,
|
|
39
|
+
vi,
|
|
40
|
+
} from "vitest";
|
|
41
|
+
|
|
42
|
+
import type { Organization } from "@stigmer/protos/ai/stigmer/tenancy/organization/v1/api_pb";
|
|
43
|
+
import { OrganizationSchema } from "@stigmer/protos/ai/stigmer/tenancy/organization/v1/api_pb";
|
|
44
|
+
import { OrganizationCommandController } from "@stigmer/protos/ai/stigmer/tenancy/organization/v1/command_pb";
|
|
45
|
+
import { OrganizationQueryController } from "@stigmer/protos/ai/stigmer/tenancy/organization/v1/query_pb";
|
|
46
|
+
import { ErrorInfoSchema } from "@stigmer/protos/google/rpc/error_details_pb";
|
|
47
|
+
|
|
48
|
+
import { loadConfig } from "../../../boot/config.js";
|
|
49
|
+
import { composeServer } from "../../../boot/compose.js";
|
|
50
|
+
import type { ComposedServer } from "../../../boot/compose.js";
|
|
51
|
+
import { createLogger } from "../../../boot/logger.js";
|
|
52
|
+
import type { Logger } from "../../../boot/logger.js";
|
|
53
|
+
import type { GateSlotName } from "../../../extensions/gate-slots.js";
|
|
54
|
+
import type { ServerExtension } from "../../../extensions/registry.js";
|
|
55
|
+
import type { PipelineStep } from "../../../pipeline/pipeline.js";
|
|
56
|
+
import { testCallerIdentity } from "../../../pipeline/__tests__/support.js";
|
|
57
|
+
import { RequestContext } from "../../../pipeline/request-context.js";
|
|
58
|
+
import { metadataOf } from "../../../pipeline/steps/shapes.js";
|
|
59
|
+
import {
|
|
60
|
+
resetOperatorIdentityForTests,
|
|
61
|
+
setOperatorIdentity,
|
|
62
|
+
} from "../../../pipeline/steps/defaults.js";
|
|
63
|
+
import { ResourceNotFoundError } from "../../../store/interface.js";
|
|
64
|
+
import type { OrganizationSlugEntry, Store } from "../../../store/interface.js";
|
|
65
|
+
import { fakeIamPolicyStore } from "../../iampolicy/__tests__/support.js";
|
|
66
|
+
import {
|
|
67
|
+
newClaimOrganizationSlugStep,
|
|
68
|
+
releaseSlugClaimAfterFailure,
|
|
69
|
+
} from "../slug-ledger.js";
|
|
70
|
+
|
|
71
|
+
const OPERATOR_EMAIL = "operator@example.com";
|
|
72
|
+
const DUPLICATE_COPY = (slug: string) =>
|
|
73
|
+
`Organization already exists: slug '${slug}'`;
|
|
74
|
+
|
|
75
|
+
async function grpcError(run: () => Promise<unknown>): Promise<ConnectError> {
|
|
76
|
+
try {
|
|
77
|
+
await run();
|
|
78
|
+
} catch (error) {
|
|
79
|
+
if (error instanceof ConnectError) {
|
|
80
|
+
return error;
|
|
81
|
+
}
|
|
82
|
+
throw error;
|
|
83
|
+
}
|
|
84
|
+
throw new Error("expected the call to fail");
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function organizationInput(slug: string) {
|
|
88
|
+
return {
|
|
89
|
+
apiVersion: "tenancy.stigmer.ai/v1",
|
|
90
|
+
kind: "Organization",
|
|
91
|
+
metadata: { name: slug, slug, org: "" },
|
|
92
|
+
spec: { description: "created by the organization slug test" },
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
describe("organization slugs on create (composed server, trusted-local posture)", () => {
|
|
97
|
+
const policies = fakeIamPolicyStore();
|
|
98
|
+
/** Slugs the pre-side-effect gate refuses while listed. */
|
|
99
|
+
const gateRefuses = new Set<string>();
|
|
100
|
+
/** Slugs whose post-persist step fails, after the row is stored. */
|
|
101
|
+
const postPersistFails = new Set<string>();
|
|
102
|
+
|
|
103
|
+
const slugOf = (organization: Organization) =>
|
|
104
|
+
organization.metadata?.slug ?? "";
|
|
105
|
+
const gateStep: PipelineStep<DescMessage> = {
|
|
106
|
+
name: "FakeOrgLimitGate",
|
|
107
|
+
execute: (ctx) => {
|
|
108
|
+
const slug = slugOf(ctx.newState as Organization);
|
|
109
|
+
if (gateRefuses.has(slug)) {
|
|
110
|
+
throw new ConnectError(
|
|
111
|
+
"fake limit on organizations",
|
|
112
|
+
Code.FailedPrecondition,
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
},
|
|
116
|
+
};
|
|
117
|
+
const postPersistStep: PipelineStep<DescMessage> = {
|
|
118
|
+
name: "FakeOrgPostPersist",
|
|
119
|
+
execute: (ctx) => {
|
|
120
|
+
const slug = slugOf(ctx.newState as Organization);
|
|
121
|
+
if (postPersistFails.has(slug)) {
|
|
122
|
+
throw new ConnectError("fake companion failed", Code.Unavailable);
|
|
123
|
+
}
|
|
124
|
+
},
|
|
125
|
+
};
|
|
126
|
+
const unit: ServerExtension = {
|
|
127
|
+
name: "fake-org-create",
|
|
128
|
+
gateSteps: new Map<GateSlotName, ReadonlyArray<PipelineStep<DescMessage>>>([
|
|
129
|
+
["org-create:pre-side-effect-gate", [gateStep]],
|
|
130
|
+
["org-create:post-persist", [postPersistStep]],
|
|
131
|
+
]),
|
|
132
|
+
drivers: { iamPolicyStore: policies },
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
let dir: string;
|
|
136
|
+
let server: ComposedServer;
|
|
137
|
+
let organizations: Client<typeof OrganizationCommandController>;
|
|
138
|
+
let organizationQuery: Client<typeof OrganizationQueryController>;
|
|
139
|
+
|
|
140
|
+
function ownersOf(org: string): number {
|
|
141
|
+
return [...policies.rows.values()].filter(
|
|
142
|
+
(policy) =>
|
|
143
|
+
policy.spec?.resource?.kind === "organization" &&
|
|
144
|
+
policy.spec.resource.id === org &&
|
|
145
|
+
policy.spec.relation === "owner",
|
|
146
|
+
).length;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
beforeAll(async () => {
|
|
150
|
+
dir = mkdtempSync(path.join(tmpdir(), "organization-slugs-test-"));
|
|
151
|
+
setOperatorIdentity(OPERATOR_EMAIL, "The Operator");
|
|
152
|
+
server = await composeServer({
|
|
153
|
+
config: loadConfig({
|
|
154
|
+
STIGMER_MODEL_REGISTRY_REFRESH: "off",
|
|
155
|
+
TEMPORAL_HOST_PORT: "127.0.0.1:1",
|
|
156
|
+
DB_PATH: path.join(dir, "stigmer.db"),
|
|
157
|
+
STORAGE_PATH: path.join(dir, "storage"),
|
|
158
|
+
ARTIFACT_LOCAL_BASE_PATH: path.join(dir, "artifacts"),
|
|
159
|
+
STIGMER_OPERATOR_EMAIL: OPERATOR_EMAIL,
|
|
160
|
+
}),
|
|
161
|
+
logger: createLogger({ level: "error", pretty: false, write: () => {} }),
|
|
162
|
+
extensions: [unit],
|
|
163
|
+
portOverride: 0,
|
|
164
|
+
host: "127.0.0.1",
|
|
165
|
+
});
|
|
166
|
+
const port = await server.start();
|
|
167
|
+
const transport = createGrpcTransport({
|
|
168
|
+
baseUrl: `http://127.0.0.1:${port}`,
|
|
169
|
+
});
|
|
170
|
+
organizations = createClient(OrganizationCommandController, transport);
|
|
171
|
+
organizationQuery = createClient(OrganizationQueryController, transport);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
afterAll(async () => {
|
|
175
|
+
await server.shutdown();
|
|
176
|
+
resetOperatorIdentityForTests();
|
|
177
|
+
rmSync(dir, { recursive: true, force: true });
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
beforeEach(() => {
|
|
181
|
+
gateRefuses.clear();
|
|
182
|
+
postPersistFails.clear();
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("of two concurrent creates of one slug, exactly one succeeds and the other is a duplicate", async () => {
|
|
186
|
+
const results = await Promise.allSettled([
|
|
187
|
+
organizations.create(organizationInput("contended")),
|
|
188
|
+
organizations.create(organizationInput("contended")),
|
|
189
|
+
]);
|
|
190
|
+
|
|
191
|
+
expect(
|
|
192
|
+
results.filter((result) => result.status === "fulfilled"),
|
|
193
|
+
).toHaveLength(1);
|
|
194
|
+
const [rejected] = results.filter(
|
|
195
|
+
(result): result is PromiseRejectedResult => result.status === "rejected",
|
|
196
|
+
);
|
|
197
|
+
const refusal = ConnectError.from(rejected?.reason);
|
|
198
|
+
expect(refusal.code).toBe(Code.AlreadyExists);
|
|
199
|
+
expect(refusal.rawMessage).toBe(DUPLICATE_COPY("contended"));
|
|
200
|
+
expect(refusal.findDetails(ErrorInfoSchema)).toEqual([]);
|
|
201
|
+
expect(ownersOf("contended")).toBe(1);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("a create racing another create's claim, before its row exists, is a duplicate and never a deleted organization's", async () => {
|
|
205
|
+
const inFlight = await server.store.organizationSlugs.claim("in-flight");
|
|
206
|
+
expect(inFlight.claimed).toBe(true);
|
|
207
|
+
|
|
208
|
+
const refusal = await grpcError(() =>
|
|
209
|
+
organizations.create(organizationInput("in-flight")),
|
|
210
|
+
);
|
|
211
|
+
expect(refusal.code).toBe(Code.AlreadyExists);
|
|
212
|
+
expect(refusal.rawMessage).toBe(DUPLICATE_COPY("in-flight"));
|
|
213
|
+
expect(refusal.findDetails(ErrorInfoSchema)).toEqual([]);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it("a pre-side-effect gate's refusal leaves no claim, so the create succeeds once the gate lets it", async () => {
|
|
217
|
+
gateRefuses.add("gated");
|
|
218
|
+
const refusal = await grpcError(() =>
|
|
219
|
+
organizations.create(organizationInput("gated")),
|
|
220
|
+
);
|
|
221
|
+
expect(refusal.code).toBe(Code.FailedPrecondition);
|
|
222
|
+
expect(await server.store.organizationSlugs.find("gated")).toBeUndefined();
|
|
223
|
+
|
|
224
|
+
gateRefuses.delete("gated");
|
|
225
|
+
const created = await organizations.create(organizationInput("gated"));
|
|
226
|
+
expect(created.metadata?.id).toBe("gated");
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it("a create that fails after its row is stored keeps its slug", async () => {
|
|
230
|
+
postPersistFails.add("half-made");
|
|
231
|
+
const refusal = await grpcError(() =>
|
|
232
|
+
organizations.create(organizationInput("half-made")),
|
|
233
|
+
);
|
|
234
|
+
expect(refusal.code).toBe(Code.Unavailable);
|
|
235
|
+
|
|
236
|
+
const entry = await server.store.organizationSlugs.find("half-made");
|
|
237
|
+
expect(entry?.retiredAt).toBe("");
|
|
238
|
+
expect(
|
|
239
|
+
(await organizationQuery.get({ value: "half-made" })).metadata?.id,
|
|
240
|
+
).toBe("half-made");
|
|
241
|
+
|
|
242
|
+
postPersistFails.delete("half-made");
|
|
243
|
+
const retry = await grpcError(() =>
|
|
244
|
+
organizations.create(organizationInput("half-made")),
|
|
245
|
+
);
|
|
246
|
+
expect(retry.code).toBe(Code.AlreadyExists);
|
|
247
|
+
expect(retry.rawMessage).toBe(DUPLICATE_COPY("half-made"));
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it("an organization no ledger entry records is refused as a duplicate by its row, and its delete retires the slug", async () => {
|
|
251
|
+
await organizations.create(organizationInput("older"));
|
|
252
|
+
// What an older binary leaves: the row, and no ledger entry.
|
|
253
|
+
const entry = await server.store.organizationSlugs.find("older");
|
|
254
|
+
await server.store.organizationSlugs.release(entry!);
|
|
255
|
+
expect(await server.store.organizationSlugs.find("older")).toBeUndefined();
|
|
256
|
+
|
|
257
|
+
const duplicate = await grpcError(() =>
|
|
258
|
+
organizations.create(organizationInput("older")),
|
|
259
|
+
);
|
|
260
|
+
expect(duplicate.code).toBe(Code.AlreadyExists);
|
|
261
|
+
expect(duplicate.rawMessage).toBe(DUPLICATE_COPY("older"));
|
|
262
|
+
|
|
263
|
+
await organizations.delete({ value: "older" });
|
|
264
|
+
const reserved = await grpcError(() =>
|
|
265
|
+
organizations.create(organizationInput("older")),
|
|
266
|
+
);
|
|
267
|
+
expect(reserved.code).toBe(Code.AlreadyExists);
|
|
268
|
+
expect(reserved.findDetails(ErrorInfoSchema)[0]?.reason).toBe(
|
|
269
|
+
"ORGANIZATION_SLUG_RESERVED",
|
|
270
|
+
);
|
|
271
|
+
});
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
describe("releaseSlugClaimAfterFailure", () => {
|
|
275
|
+
const entry: OrganizationSlugEntry = {
|
|
276
|
+
slug: "acme",
|
|
277
|
+
claimedAt: "2026-09-28T00:00:00.000Z",
|
|
278
|
+
retiredAt: "",
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
function logger(): Logger & { errors: string[] } {
|
|
282
|
+
const errors: string[] = [];
|
|
283
|
+
return {
|
|
284
|
+
errors,
|
|
285
|
+
error: (message: string) => errors.push(message),
|
|
286
|
+
warn: () => {},
|
|
287
|
+
info: () => {},
|
|
288
|
+
debug: () => {},
|
|
289
|
+
} as unknown as Logger & { errors: string[] };
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** A store double: the organization row's read and the ledger's release. */
|
|
293
|
+
function storeWith(
|
|
294
|
+
rowRead: () => Promise<unknown>,
|
|
295
|
+
release: (claim: OrganizationSlugEntry) => Promise<void>,
|
|
296
|
+
): Store {
|
|
297
|
+
return {
|
|
298
|
+
getResource: rowRead,
|
|
299
|
+
organizationSlugs: {
|
|
300
|
+
claim: async () => ({ claimed: true, entry }),
|
|
301
|
+
retire: async () => {},
|
|
302
|
+
release,
|
|
303
|
+
find: async () => entry,
|
|
304
|
+
},
|
|
305
|
+
} as unknown as Store;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** A request whose ClaimOrganizationSlug won `entry`. */
|
|
309
|
+
async function claimedRequest(): Promise<
|
|
310
|
+
RequestContext<typeof OrganizationSchema>
|
|
311
|
+
> {
|
|
312
|
+
const ctx = new RequestContext(
|
|
313
|
+
OrganizationSchema,
|
|
314
|
+
create(OrganizationSchema, { metadata: { slug: "acme" } }),
|
|
315
|
+
testCallerIdentity(),
|
|
316
|
+
);
|
|
317
|
+
const claiming = {
|
|
318
|
+
organizationSlugs: { claim: async () => ({ claimed: true, entry }) },
|
|
319
|
+
} as unknown as Store;
|
|
320
|
+
await newClaimOrganizationSlugStep(claiming).execute(ctx);
|
|
321
|
+
expect(metadataOf(ctx.newState)?.slug).toBe("acme");
|
|
322
|
+
return ctx;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
it("frees the claim when the organization was never stored", async () => {
|
|
326
|
+
const release = vi.fn(async () => {});
|
|
327
|
+
const store = storeWith(async () => {
|
|
328
|
+
throw new ResourceNotFoundError("organization acme");
|
|
329
|
+
}, release);
|
|
330
|
+
|
|
331
|
+
await releaseSlugClaimAfterFailure(store, logger(), await claimedRequest());
|
|
332
|
+
expect(release).toHaveBeenCalledWith(entry);
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
it("keeps the claim when the organization was stored", async () => {
|
|
336
|
+
const release = vi.fn(async () => {});
|
|
337
|
+
const store = storeWith(async () => ({}), release);
|
|
338
|
+
|
|
339
|
+
await releaseSlugClaimAfterFailure(store, logger(), await claimedRequest());
|
|
340
|
+
expect(release).not.toHaveBeenCalled();
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
it("keeps the claim, and logs, when the row cannot be read or the release faults", async () => {
|
|
344
|
+
const release = vi.fn(async () => {});
|
|
345
|
+
const unreadable = logger();
|
|
346
|
+
await releaseSlugClaimAfterFailure(
|
|
347
|
+
storeWith(async () => {
|
|
348
|
+
throw new Error("database locked");
|
|
349
|
+
}, release),
|
|
350
|
+
unreadable,
|
|
351
|
+
await claimedRequest(),
|
|
352
|
+
);
|
|
353
|
+
expect(release).not.toHaveBeenCalled();
|
|
354
|
+
expect(unreadable.errors).toHaveLength(1);
|
|
355
|
+
|
|
356
|
+
const faulting = logger();
|
|
357
|
+
await releaseSlugClaimAfterFailure(
|
|
358
|
+
storeWith(
|
|
359
|
+
async () => {
|
|
360
|
+
throw new ResourceNotFoundError("organization acme");
|
|
361
|
+
},
|
|
362
|
+
async () => {
|
|
363
|
+
throw new Error("database locked");
|
|
364
|
+
},
|
|
365
|
+
),
|
|
366
|
+
faulting,
|
|
367
|
+
await claimedRequest(),
|
|
368
|
+
);
|
|
369
|
+
expect(faulting.errors).toHaveLength(1);
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
it("does nothing for a request that never claimed", async () => {
|
|
373
|
+
const release = vi.fn(async () => {});
|
|
374
|
+
const store = storeWith(async () => {
|
|
375
|
+
throw new ResourceNotFoundError("organization acme");
|
|
376
|
+
}, release);
|
|
377
|
+
const unclaimed = new RequestContext(
|
|
378
|
+
OrganizationSchema,
|
|
379
|
+
create(OrganizationSchema, { metadata: { slug: "acme" } }),
|
|
380
|
+
testCallerIdentity(),
|
|
381
|
+
);
|
|
382
|
+
|
|
383
|
+
await releaseSlugClaimAfterFailure(store, logger(), unclaimed);
|
|
384
|
+
expect(release).not.toHaveBeenCalled();
|
|
385
|
+
});
|
|
386
|
+
});
|
|
@@ -106,6 +106,11 @@ import { newValidateVisibilityStep } from "../../pipeline/steps/validate-visibil
|
|
|
106
106
|
import { ResourceNotFoundError } from "../../store/interface.js";
|
|
107
107
|
import type { Store } from "../../store/interface.js";
|
|
108
108
|
import { organizationSearchExtractor } from "./search-extractor.js";
|
|
109
|
+
import {
|
|
110
|
+
newClaimOrganizationSlugStep,
|
|
111
|
+
newRetireOrganizationSlugStep,
|
|
112
|
+
releaseSlugClaimAfterFailure,
|
|
113
|
+
} from "./slug-ledger.js";
|
|
109
114
|
import {
|
|
110
115
|
newCheckOrgDuplicateStep,
|
|
111
116
|
newCopySlugToIdStep,
|
|
@@ -175,6 +180,13 @@ function kindOf(ctx: HandlerContext): ApiResourceKind {
|
|
|
175
180
|
* refusal there leaves no organization behind. It is where a limit on
|
|
176
181
|
* which organizations may exist is enforced.
|
|
177
182
|
*
|
|
183
|
+
* ClaimOrganizationSlug follows the slot, immediately before Persist: the
|
|
184
|
+
* slug is claimed in the ledger atomically, so of two concurrent creates of
|
|
185
|
+
* one slug exactly one proceeds, and a slug any organization ever held is
|
|
186
|
+
* refused (slug-ledger.ts). When the chain fails after the claim and the
|
|
187
|
+
* organization was never stored, the claim is released so a retry can take
|
|
188
|
+
* the slug.
|
|
189
|
+
*
|
|
178
190
|
* The post-persist gate slot splices after Persist, before IndexSearch —
|
|
179
191
|
* the verified Java OrganizationCreateHandler ordering (FGA tuple
|
|
180
192
|
* seeding, billing account getOrCreate: synchronous, a failure fails the
|
|
@@ -218,6 +230,7 @@ async function createOrganization(
|
|
|
218
230
|
builder.addStep(step);
|
|
219
231
|
}
|
|
220
232
|
builder
|
|
233
|
+
.addStep(newClaimOrganizationSlugStep(deps.store))
|
|
221
234
|
.addStep(newPersistStep(deps.store))
|
|
222
235
|
// The C2 tuple step runs BEFORE the post-persist slot — the verified
|
|
223
236
|
// Java order (createAuthorizationTuples → linkManagedOrgToIdentityProvider
|
|
@@ -236,12 +249,17 @@ async function createOrganization(
|
|
|
236
249
|
)) {
|
|
237
250
|
builder.addStep(step);
|
|
238
251
|
}
|
|
239
|
-
|
|
252
|
+
const pipeline = builder
|
|
240
253
|
.addStep(
|
|
241
254
|
newIndexSearchStep(deps.store, organizationSearchExtractor, deps.logger),
|
|
242
255
|
)
|
|
243
|
-
.build()
|
|
244
|
-
|
|
256
|
+
.build();
|
|
257
|
+
try {
|
|
258
|
+
await pipeline.execute(reqCtx);
|
|
259
|
+
} catch (error) {
|
|
260
|
+
await releaseSlugClaimAfterFailure(deps.store, deps.logger, reqCtx);
|
|
261
|
+
throw error;
|
|
262
|
+
}
|
|
245
263
|
return reqCtx.newState;
|
|
246
264
|
}
|
|
247
265
|
|
|
@@ -329,21 +347,24 @@ async function apply(
|
|
|
329
347
|
/**
|
|
330
348
|
* Delete — returns the deleted organization (gRPC audit-trail convention).
|
|
331
349
|
*
|
|
332
|
-
*
|
|
333
|
-
*
|
|
334
|
-
*
|
|
335
|
-
*
|
|
350
|
+
* An organization's id is its slug, and a slug is never taken again
|
|
351
|
+
* (slug-ledger.ts), so whatever outlives the row can never pass to a new
|
|
352
|
+
* holder of the slug. Everything that grants on the organization still
|
|
353
|
+
* goes before its row, and a fault stops the delete with the organization
|
|
354
|
+
* intact, so nothing it granted outlives it. After the load:
|
|
336
355
|
*
|
|
337
|
-
* 1. the
|
|
356
|
+
* 1. RetireOrganizationSlug, the slug marked retired in the ledger, so it
|
|
357
|
+
* is retired before the row can go;
|
|
358
|
+
* 2. the `org-delete:pre-delete` slot, where an edition refuses or
|
|
338
359
|
* removes the rows it keeps for the organization (empty in OSS);
|
|
339
|
-
*
|
|
360
|
+
* 3. RevokeOrganizationPolicies, every policy row naming the
|
|
340
361
|
* organization, through the grant path, never caught;
|
|
341
|
-
*
|
|
342
|
-
*
|
|
362
|
+
* 4. the row;
|
|
363
|
+
* 5. CleanupIamPolicies, the lifecycle's post-delete event: best-effort
|
|
343
364
|
* like every delete chain's, it revokes whatever a concurrent write
|
|
344
|
-
* named the organization with after step
|
|
365
|
+
* named the organization with after step 3, and runs a composed
|
|
345
366
|
* driver's post-delete companions;
|
|
346
|
-
*
|
|
367
|
+
* 6. the search entry.
|
|
347
368
|
*/
|
|
348
369
|
async function deleteOrganization(
|
|
349
370
|
deps: OrganizationControllerDeps,
|
|
@@ -366,7 +387,8 @@ async function deleteOrganization(
|
|
|
366
387
|
)
|
|
367
388
|
.addStep(newValidateProtoStep())
|
|
368
389
|
.addStep(newExtractResourceIdStep())
|
|
369
|
-
.addStep(newLoadExistingForDeleteStep(deps.store, OrganizationSchema))
|
|
390
|
+
.addStep(newLoadExistingForDeleteStep(deps.store, OrganizationSchema))
|
|
391
|
+
.addStep(newRetireOrganizationSlugStep<DeleteInput>(deps.store));
|
|
370
392
|
// The pre-delete gate slot (see the doc comment above). Empty in OSS.
|
|
371
393
|
for (const step of stepsForSlot<DeleteInput>(
|
|
372
394
|
deps.gateSteps,
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The organization domain's use of the slug ledger (the Store's
|
|
3
|
+
* `organizationSlugs`; store/interface.ts states its guarantees): an
|
|
4
|
+
* organization's slug is its for good.
|
|
5
|
+
*
|
|
6
|
+
* An organization's id is its slug, and the delete removes the row. Rows
|
|
7
|
+
* that name the organization can outlive it (every organization-scoped
|
|
8
|
+
* resource names it in `metadata.org`, and an edition may keep its own rows
|
|
9
|
+
* by the id), so a slug taken again would hand them all to the new holder.
|
|
10
|
+
* The ledger closes that: the create claims the slug atomically, the delete
|
|
11
|
+
* retires it, and nothing ever frees a retired slug.
|
|
12
|
+
*
|
|
13
|
+
* The create touches the ledger twice. CheckDuplicate reads it first, so a
|
|
14
|
+
* taken slug is refused before any gate runs (steps.ts). ClaimOrganizationSlug
|
|
15
|
+
* then claims it immediately before Persist, after the pre-side-effect gate
|
|
16
|
+
* slot, so a gate's refusal still leaves nothing written; of two concurrent
|
|
17
|
+
* creates of one slug, exactly one claim wins, which the row alone cannot
|
|
18
|
+
* give because `saveResource` upserts. A create that fails after its claim
|
|
19
|
+
* and before its row is stored releases the claim, so the caller's retry
|
|
20
|
+
* can take the slug again; a create that fails after the row keeps it,
|
|
21
|
+
* because the organization exists.
|
|
22
|
+
*
|
|
23
|
+
* The refusal says why. A retired slug answers AlreadyExists carrying the
|
|
24
|
+
* ORGANIZATION_SLUG_RESERVED reason (the organization create's contract
|
|
25
|
+
* documents it), so the CLI and the console can tell a person the slug
|
|
26
|
+
* belonged to a deleted organization. A held slug keeps the existing copy
|
|
27
|
+
* with no reason: it covers a live organization and also a create between
|
|
28
|
+
* its claim and its row, which must never read as "deleted". Both keep the
|
|
29
|
+
* AlreadyExists code, which the personal-organization retry keys on.
|
|
30
|
+
*
|
|
31
|
+
* Proven by __tests__/organization-slugs.test.ts and the organization
|
|
32
|
+
* conformance suite.
|
|
33
|
+
*/
|
|
34
|
+
import type { DescMessage } from "@bufbuild/protobuf";
|
|
35
|
+
import type { ConnectError } from "@connectrpc/connect";
|
|
36
|
+
|
|
37
|
+
import type { Logger } from "../../boot/logger.js";
|
|
38
|
+
import { ResourceNotFoundError } from "../../store/interface.js";
|
|
39
|
+
import type { OrganizationSlugEntry, Store } from "../../store/interface.js";
|
|
40
|
+
import {
|
|
41
|
+
alreadyExistsError,
|
|
42
|
+
alreadyExistsWithReasonError,
|
|
43
|
+
internalError,
|
|
44
|
+
} from "../../pipeline/errors.js";
|
|
45
|
+
import type { PipelineStep } from "../../pipeline/pipeline.js";
|
|
46
|
+
import type { RequestContext } from "../../pipeline/request-context.js";
|
|
47
|
+
import { EXISTING_RESOURCE_KEY } from "../../pipeline/steps/load-existing.js";
|
|
48
|
+
import { metadataOf } from "../../pipeline/steps/shapes.js";
|
|
49
|
+
|
|
50
|
+
import { ApiResourceKind } from "@stigmer/protos/ai/stigmer/commons/apiresource/apiresourcekind/api_resource_kind_pb";
|
|
51
|
+
import type { Organization } from "@stigmer/protos/ai/stigmer/tenancy/organization/v1/api_pb";
|
|
52
|
+
import { OrganizationSchema } from "@stigmer/protos/ai/stigmer/tenancy/organization/v1/api_pb";
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The ErrorInfo reason a create of a retired slug carries: a deleted
|
|
56
|
+
* organization held the slug, and a slug is never reused. Wire contract,
|
|
57
|
+
* documented on OrganizationCommandController.create; metadata `slug`.
|
|
58
|
+
*/
|
|
59
|
+
export const ORGANIZATION_SLUG_RESERVED = "ORGANIZATION_SLUG_RESERVED";
|
|
60
|
+
|
|
61
|
+
/** Where ClaimOrganizationSlug leaves the entry it won, for the release after a failure. */
|
|
62
|
+
const CLAIMED_SLUG_KEY = "organizationSlugClaim";
|
|
63
|
+
|
|
64
|
+
/** The copy of a retired slug's refusal. */
|
|
65
|
+
export function organizationSlugReservedMessage(slug: string): string {
|
|
66
|
+
return `Organization slug '${slug}' belonged to an organization that was deleted, and a slug is never reused`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The refusal for a slug an entry already holds: the reserved refusal for a
|
|
71
|
+
* retired entry, the existing duplicate copy for one still held.
|
|
72
|
+
*/
|
|
73
|
+
export function refusalForHeldSlug(entry: OrganizationSlugEntry): ConnectError {
|
|
74
|
+
if (entry.retiredAt !== "") {
|
|
75
|
+
return alreadyExistsWithReasonError(
|
|
76
|
+
organizationSlugReservedMessage(entry.slug),
|
|
77
|
+
{ reason: ORGANIZATION_SLUG_RESERVED, metadata: { slug: entry.slug } },
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
return alreadyExistsError("Organization", `slug '${entry.slug}'`);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Claims the new organization's slug, immediately before Persist. A lost
|
|
85
|
+
* claim is refused by the entry that holds the slug; a won claim is left in
|
|
86
|
+
* the request for releaseSlugClaimAfterFailure.
|
|
87
|
+
*/
|
|
88
|
+
export function newClaimOrganizationSlugStep(
|
|
89
|
+
store: Store,
|
|
90
|
+
): PipelineStep<typeof OrganizationSchema> {
|
|
91
|
+
return {
|
|
92
|
+
name: "ClaimOrganizationSlug",
|
|
93
|
+
async execute(
|
|
94
|
+
ctx: RequestContext<typeof OrganizationSchema>,
|
|
95
|
+
): Promise<void> {
|
|
96
|
+
const slug = metadataOf(ctx.newState)?.slug ?? "";
|
|
97
|
+
// ResolveSlug and CheckDuplicate run first, so an empty slug here is
|
|
98
|
+
// a server-side ordering bug, not bad client input.
|
|
99
|
+
if (slug === "") {
|
|
100
|
+
throw internalError(
|
|
101
|
+
new Error("organization slug is empty"),
|
|
102
|
+
"failed to claim the organization slug",
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
let claim;
|
|
106
|
+
try {
|
|
107
|
+
claim = await store.organizationSlugs.claim(slug);
|
|
108
|
+
} catch (error) {
|
|
109
|
+
throw internalError(error, "failed to claim the organization slug");
|
|
110
|
+
}
|
|
111
|
+
if (!claim.claimed) {
|
|
112
|
+
throw refusalForHeldSlug(claim.entry);
|
|
113
|
+
}
|
|
114
|
+
ctx.set(CLAIMED_SLUG_KEY, claim.entry);
|
|
115
|
+
},
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Frees the slug a failed create claimed, when its organization was never
|
|
121
|
+
* stored, so the caller's retry can take the slug again. Called by the
|
|
122
|
+
* create around its chain, as send-signal.ts releases a dedupe claim.
|
|
123
|
+
*
|
|
124
|
+
* Whether the row exists decides, rather than which step failed: a Persist
|
|
125
|
+
* that stored the row and then failed keeps the claim, as it must, because
|
|
126
|
+
* the organization exists. Any fault here is logged and leaves the slug
|
|
127
|
+
* claimed with no organization, which nothing can then take; that is a
|
|
128
|
+
* hand's to resolve, and far rarer than the failure it follows.
|
|
129
|
+
*/
|
|
130
|
+
export async function releaseSlugClaimAfterFailure(
|
|
131
|
+
store: Store,
|
|
132
|
+
logger: Logger,
|
|
133
|
+
ctx: RequestContext<typeof OrganizationSchema>,
|
|
134
|
+
): Promise<void> {
|
|
135
|
+
const entry = ctx.get(CLAIMED_SLUG_KEY) as OrganizationSlugEntry | undefined;
|
|
136
|
+
if (entry === undefined) {
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
try {
|
|
140
|
+
await store.getResource(
|
|
141
|
+
ApiResourceKind.organization,
|
|
142
|
+
entry.slug,
|
|
143
|
+
OrganizationSchema,
|
|
144
|
+
);
|
|
145
|
+
return; // stored: the organization exists and keeps its slug
|
|
146
|
+
} catch (error) {
|
|
147
|
+
if (!(error instanceof ResourceNotFoundError)) {
|
|
148
|
+
logger.error(
|
|
149
|
+
"organization create failed and its slug claim could not be checked; the slug stays claimed",
|
|
150
|
+
{
|
|
151
|
+
slug: entry.slug,
|
|
152
|
+
error: error instanceof Error ? error.message : String(error),
|
|
153
|
+
},
|
|
154
|
+
);
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
try {
|
|
159
|
+
await store.organizationSlugs.release(entry);
|
|
160
|
+
} catch (error) {
|
|
161
|
+
logger.error(
|
|
162
|
+
"organization create failed and its slug claim could not be released; the slug stays claimed",
|
|
163
|
+
{
|
|
164
|
+
slug: entry.slug,
|
|
165
|
+
error: error instanceof Error ? error.message : String(error),
|
|
166
|
+
},
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Retires the organization's slug before anything else the delete does,
|
|
173
|
+
* right after the organization is loaded. Its entry may not exist yet (an
|
|
174
|
+
* organization created before the ledger, or by an older binary during a
|
|
175
|
+
* rolling upgrade), and retire records it either way, so the slug is
|
|
176
|
+
* retired before the row can go.
|
|
177
|
+
*
|
|
178
|
+
* A fault fails the delete with the organization intact. A delete that
|
|
179
|
+
* fails later (an edition's refusal on the pre-delete slot, a revocation
|
|
180
|
+
* fault) leaves a live organization whose slug reads as retired; nothing
|
|
181
|
+
* observes that, because a create of the slug is refused either way, and a
|
|
182
|
+
* retry of the delete resumes.
|
|
183
|
+
*/
|
|
184
|
+
export function newRetireOrganizationSlugStep<Desc extends DescMessage>(
|
|
185
|
+
store: Store,
|
|
186
|
+
): PipelineStep<Desc> {
|
|
187
|
+
return {
|
|
188
|
+
name: "RetireOrganizationSlug",
|
|
189
|
+
async execute(ctx: RequestContext<Desc>): Promise<void> {
|
|
190
|
+
const organization = ctx.get(EXISTING_RESOURCE_KEY) as
|
|
191
|
+
| Organization
|
|
192
|
+
| undefined;
|
|
193
|
+
const id = organization?.metadata?.id ?? "";
|
|
194
|
+
if (id === "") {
|
|
195
|
+
throw internalError(
|
|
196
|
+
new Error(
|
|
197
|
+
"organization delete reached RetireOrganizationSlug without its loaded row",
|
|
198
|
+
),
|
|
199
|
+
"failed to retire the organization slug",
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
try {
|
|
203
|
+
await store.organizationSlugs.retire(id);
|
|
204
|
+
} catch (error) {
|
|
205
|
+
throw internalError(error, "failed to retire the organization slug");
|
|
206
|
+
}
|
|
207
|
+
},
|
|
208
|
+
};
|
|
209
|
+
}
|