@stigmer/server 3.38.3 → 3.39.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.
- 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/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/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 +110 -0
- package/src/domain/agentinstance/controller.ts +9 -5
- 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/extensions/gate-slots.ts +7 -5
- package/src/index.ts +11 -0
- package/src/pipeline/__tests__/blind-not-found.test.ts +0 -4
- 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
|
@@ -1,20 +1,24 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Pins the organization delete's order through a composed server in the
|
|
3
|
-
* trusted-local posture:
|
|
4
|
-
* before its row, and a fault stops the delete
|
|
5
|
-
* place. An organization's id is its slug and
|
|
6
|
-
*
|
|
3
|
+
* trusted-local posture: the slug is retired first, everything that grants
|
|
4
|
+
* on the organization goes before its row, and a fault stops the delete
|
|
5
|
+
* with the organization in place. An organization's id is its slug, and a
|
|
6
|
+
* slug is never taken again, so nothing a deleted organization left behind
|
|
7
|
+
* can pass to a new holder.
|
|
7
8
|
*
|
|
8
9
|
* - the `org-delete:pre-delete` slot runs with the organization loaded
|
|
9
10
|
* and still stored, and its rows still in place;
|
|
10
11
|
* - a slot step's refusal answers its own code and copy, and leaves the
|
|
11
|
-
* organization and its rows;
|
|
12
|
+
* organization and its rows, its slug already retired;
|
|
13
|
+
* - a fault retiring the slug fails the delete before anything else, with
|
|
14
|
+
* the organization and its rows in place;
|
|
12
15
|
* - the organization's policy rows are revoked before its row, as
|
|
13
16
|
* principal and as resource: a revocation fault answers Internal with
|
|
14
17
|
* fixed copy, leaves the organization and its owner row, and a retry
|
|
15
18
|
* completes the delete;
|
|
16
|
-
* - a
|
|
17
|
-
* organization
|
|
19
|
+
* - a create of a deleted organization's slug is refused with the
|
|
20
|
+
* ORGANIZATION_SLUG_RESERVED reason, and no row of the old organization
|
|
21
|
+
* gains an owner.
|
|
18
22
|
*
|
|
19
23
|
* The policy store is the library's in-memory double, composed as the
|
|
20
24
|
* IamPolicy store driver so a test can make one row's delete fault; the
|
|
@@ -28,13 +32,22 @@ import type { DescMessage } from "@bufbuild/protobuf";
|
|
|
28
32
|
import { Code, ConnectError, createClient } from "@connectrpc/connect";
|
|
29
33
|
import type { Client, Transport } from "@connectrpc/connect";
|
|
30
34
|
import { createGrpcTransport } from "@connectrpc/connect-node";
|
|
31
|
-
import {
|
|
35
|
+
import {
|
|
36
|
+
afterAll,
|
|
37
|
+
beforeAll,
|
|
38
|
+
beforeEach,
|
|
39
|
+
describe,
|
|
40
|
+
expect,
|
|
41
|
+
it,
|
|
42
|
+
vi,
|
|
43
|
+
} from "vitest";
|
|
32
44
|
|
|
33
45
|
import { IamPolicyCommandController } from "@stigmer/protos/ai/stigmer/iam/iampolicy/v1/command_pb";
|
|
34
46
|
import type { IamPolicy } from "@stigmer/protos/ai/stigmer/iam/iampolicy/v1/api_pb";
|
|
35
47
|
import type { Organization } from "@stigmer/protos/ai/stigmer/tenancy/organization/v1/api_pb";
|
|
36
48
|
import { OrganizationCommandController } from "@stigmer/protos/ai/stigmer/tenancy/organization/v1/command_pb";
|
|
37
49
|
import { OrganizationQueryController } from "@stigmer/protos/ai/stigmer/tenancy/organization/v1/query_pb";
|
|
50
|
+
import { ErrorInfoSchema } from "@stigmer/protos/google/rpc/error_details_pb";
|
|
38
51
|
|
|
39
52
|
import { loadConfig } from "../../../boot/config.js";
|
|
40
53
|
import { composeServer } from "../../../boot/compose.js";
|
|
@@ -215,6 +228,36 @@ describe("organization delete (composed server, trusted-local posture)", () => {
|
|
|
215
228
|
(await organizationQuery.get({ value: REFUSED_SLUG })).metadata?.id,
|
|
216
229
|
).toBe(REFUSED_SLUG);
|
|
217
230
|
expect(rowsNaming(REFUSED_SLUG)).toHaveLength(3);
|
|
231
|
+
// The slug was retired before the slot refused; the organization lives
|
|
232
|
+
// on, and its slug is refused to a create either way.
|
|
233
|
+
expect(
|
|
234
|
+
(await server.store.organizationSlugs.find(REFUSED_SLUG))?.retiredAt,
|
|
235
|
+
).not.toBe("");
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it("a fault retiring the slug fails the delete before anything else, with the organization and its rows in place", async () => {
|
|
239
|
+
await organizationWithRows("delete-retire-faults");
|
|
240
|
+
const retire = vi
|
|
241
|
+
.spyOn(server.store.organizationSlugs, "retire")
|
|
242
|
+
.mockRejectedValueOnce(new Error("ledger unavailable"));
|
|
243
|
+
try {
|
|
244
|
+
const failed = await grpcError(() =>
|
|
245
|
+
organizations.delete({ value: "delete-retire-faults" }),
|
|
246
|
+
);
|
|
247
|
+
expect(failed.code).toBe(Code.Internal);
|
|
248
|
+
expect(failed.rawMessage).toBe("failed to retire the organization slug");
|
|
249
|
+
} finally {
|
|
250
|
+
retire.mockRestore();
|
|
251
|
+
}
|
|
252
|
+
expect(seenBySlot.has("delete-retire-faults")).toBe(false);
|
|
253
|
+
expect(
|
|
254
|
+
(await organizationQuery.get({ value: "delete-retire-faults" })).metadata
|
|
255
|
+
?.id,
|
|
256
|
+
).toBe("delete-retire-faults");
|
|
257
|
+
expect(rowsNaming("delete-retire-faults")).toHaveLength(3);
|
|
258
|
+
|
|
259
|
+
await organizations.delete({ value: "delete-retire-faults" });
|
|
260
|
+
expect(rowsNaming("delete-retire-faults")).toEqual([]);
|
|
218
261
|
});
|
|
219
262
|
|
|
220
263
|
it("a revocation fault fails the delete with the organization and its owner in place, and the retry completes it", async () => {
|
|
@@ -248,19 +291,31 @@ describe("organization delete (composed server, trusted-local posture)", () => {
|
|
|
248
291
|
expect(rowsNaming("delete-faults")).toEqual([]);
|
|
249
292
|
});
|
|
250
293
|
|
|
251
|
-
it("a slug
|
|
294
|
+
it("a deleted organization's slug is never taken again: the create is refused with the reserved reason", async () => {
|
|
252
295
|
await organizationWithRows("delete-reborn");
|
|
253
296
|
await organizations.delete({ value: "delete-reborn" });
|
|
254
297
|
|
|
255
|
-
await
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
298
|
+
const refused = await grpcError(() =>
|
|
299
|
+
organizations.create({
|
|
300
|
+
apiVersion: "tenancy.stigmer.ai/v1",
|
|
301
|
+
kind: "Organization",
|
|
302
|
+
metadata: { name: "delete-reborn", slug: "delete-reborn", org: "" },
|
|
303
|
+
spec: { description: "the slug, taken again" },
|
|
304
|
+
}),
|
|
305
|
+
);
|
|
261
306
|
|
|
262
|
-
expect(
|
|
263
|
-
|
|
264
|
-
|
|
307
|
+
expect(refused.code).toBe(Code.AlreadyExists);
|
|
308
|
+
expect(refused.rawMessage).toBe(
|
|
309
|
+
"Organization slug 'delete-reborn' belonged to an organization that was deleted, and a slug is never reused",
|
|
310
|
+
);
|
|
311
|
+
const [reason] = refused.findDetails(ErrorInfoSchema);
|
|
312
|
+
expect(reason?.reason).toBe("ORGANIZATION_SLUG_RESERVED");
|
|
313
|
+
expect(reason?.domain).toBe("stigmer.ai");
|
|
314
|
+
expect(reason?.metadata).toEqual({ slug: "delete-reborn" });
|
|
315
|
+
expect(rowsNaming("delete-reborn")).toEqual([]);
|
|
316
|
+
const gone = await grpcError(() =>
|
|
317
|
+
organizationQuery.get({ value: "delete-reborn" }),
|
|
318
|
+
);
|
|
319
|
+
expect(gone.code).toBe(Code.NotFound);
|
|
265
320
|
});
|
|
266
321
|
});
|
|
@@ -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,
|