@stigmer/server 3.14.1 → 3.15.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/boot/compose.d.ts +8 -1
- package/dist/boot/compose.d.ts.map +1 -1
- package/dist/boot/compose.js +98 -12
- package/dist/boot/compose.js.map +1 -1
- package/dist/domain/apikey/verifier.d.ts +8 -1
- package/dist/domain/apikey/verifier.d.ts.map +1 -1
- package/dist/domain/apikey/verifier.js +14 -2
- package/dist/domain/apikey/verifier.js.map +1 -1
- package/dist/domain/identityaccount/constants.d.ts +66 -0
- package/dist/domain/identityaccount/constants.d.ts.map +1 -0
- package/dist/domain/identityaccount/constants.js +153 -0
- package/dist/domain/identityaccount/constants.js.map +1 -0
- package/dist/domain/identityaccount/controller.d.ts +34 -0
- package/dist/domain/identityaccount/controller.d.ts.map +1 -0
- package/dist/domain/identityaccount/controller.js +342 -0
- package/dist/domain/identityaccount/controller.js.map +1 -0
- package/dist/domain/identityaccount/operator.d.ts +14 -0
- package/dist/domain/identityaccount/operator.d.ts.map +1 -0
- package/dist/domain/identityaccount/operator.js +67 -0
- package/dist/domain/identityaccount/operator.js.map +1 -0
- package/dist/domain/identityaccount/provisioning.d.ts +52 -0
- package/dist/domain/identityaccount/provisioning.d.ts.map +1 -0
- package/dist/domain/identityaccount/provisioning.js +114 -0
- package/dist/domain/identityaccount/provisioning.js.map +1 -0
- package/dist/domain/identityaccount/resolve.d.ts +36 -0
- package/dist/domain/identityaccount/resolve.d.ts.map +1 -0
- package/dist/domain/identityaccount/resolve.js +12 -0
- package/dist/domain/identityaccount/resolve.js.map +1 -0
- package/dist/domain/identityaccount/resource-store.d.ts +4 -0
- package/dist/domain/identityaccount/resource-store.d.ts.map +1 -0
- package/dist/domain/identityaccount/resource-store.js +131 -0
- package/dist/domain/identityaccount/resource-store.js.map +1 -0
- package/dist/domain/identityaccount/steps.d.ts +79 -0
- package/dist/domain/identityaccount/steps.d.ts.map +1 -0
- package/dist/domain/identityaccount/steps.js +291 -0
- package/dist/domain/identityaccount/steps.js.map +1 -0
- package/dist/domain/identityaccount/store-contract.d.ts +22 -0
- package/dist/domain/identityaccount/store-contract.d.ts.map +1 -0
- package/dist/domain/identityaccount/store-contract.js +307 -0
- package/dist/domain/identityaccount/store-contract.js.map +1 -0
- package/dist/domain/identityaccount/store.d.ts +66 -0
- package/dist/domain/identityaccount/store.d.ts.map +1 -0
- package/dist/domain/identityaccount/store.js +13 -0
- package/dist/domain/identityaccount/store.js.map +1 -0
- package/dist/extensions/drivers.d.ts +26 -0
- package/dist/extensions/drivers.d.ts.map +1 -1
- package/dist/extensions/gate-slots.d.ts +16 -1
- package/dist/extensions/gate-slots.d.ts.map +1 -1
- package/dist/extensions/gate-slots.js +1 -0
- package/dist/extensions/gate-slots.js.map +1 -1
- package/dist/extensions/identity-federation.d.ts +36 -0
- package/dist/extensions/identity-federation.d.ts.map +1 -0
- package/dist/extensions/identity-federation.js +2 -0
- package/dist/extensions/identity-federation.js.map +1 -0
- package/dist/extensions/registry.d.ts +18 -1
- package/dist/extensions/registry.d.ts.map +1 -1
- package/dist/extensions/registry.js +20 -0
- package/dist/extensions/registry.js.map +1 -1
- package/dist/identity/oidc-discovery.d.ts +36 -0
- package/dist/identity/oidc-discovery.d.ts.map +1 -0
- package/dist/identity/oidc-discovery.js +60 -0
- package/dist/identity/oidc-discovery.js.map +1 -0
- package/dist/identity/oidc-userinfo.d.ts +30 -0
- package/dist/identity/oidc-userinfo.d.ts.map +1 -0
- package/dist/identity/oidc-userinfo.js +28 -0
- package/dist/identity/oidc-userinfo.js.map +1 -0
- package/dist/identity/oidc-verifier.d.ts +6 -0
- package/dist/identity/oidc-verifier.d.ts.map +1 -1
- package/dist/identity/oidc-verifier.js +35 -34
- package/dist/identity/oidc-verifier.js.map +1 -1
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/pipeline/interceptors/auth.d.ts +10 -0
- package/dist/pipeline/interceptors/auth.d.ts.map +1 -1
- package/dist/pipeline/interceptors/auth.js +9 -1
- package/dist/pipeline/interceptors/auth.js.map +1 -1
- package/package.json +4 -4
- package/src/boot/__tests__/auth-enabled-composition.test.ts +226 -2
- package/src/boot/compose.ts +118 -13
- package/src/domain/apikey/__tests__/verifier.test.ts +146 -22
- package/src/domain/apikey/verifier.ts +24 -2
- package/src/domain/identityaccount/__tests__/constants.test.ts +155 -0
- package/src/domain/identityaccount/__tests__/identityaccount.test.ts +484 -0
- package/src/domain/identityaccount/__tests__/operator.test.ts +180 -0
- package/src/domain/identityaccount/__tests__/provisioning.test.ts +281 -0
- package/src/domain/identityaccount/__tests__/resolve.test.ts +114 -0
- package/src/domain/identityaccount/__tests__/resource-store.test.ts +216 -0
- package/src/domain/identityaccount/__tests__/support.ts +58 -0
- package/src/domain/identityaccount/constants.ts +178 -0
- package/src/domain/identityaccount/controller.ts +667 -0
- package/src/domain/identityaccount/operator.ts +94 -0
- package/src/domain/identityaccount/provisioning.ts +182 -0
- package/src/domain/identityaccount/resolve.ts +52 -0
- package/src/domain/identityaccount/resource-store.ts +157 -0
- package/src/domain/identityaccount/steps.ts +377 -0
- package/src/domain/identityaccount/store-contract.ts +471 -0
- package/src/domain/identityaccount/store.ts +70 -0
- package/src/extensions/__tests__/identity-account-composed.test.ts +803 -0
- package/src/extensions/__tests__/identity-account-points.test.ts +129 -0
- package/src/extensions/__tests__/registry.test.ts +4 -0
- package/src/extensions/__tests__/tier-truthfulness.test.ts +299 -0
- package/src/extensions/drivers.ts +26 -0
- package/src/extensions/gate-slots.ts +16 -0
- package/src/extensions/identity-federation.ts +58 -0
- package/src/extensions/registry.ts +44 -1
- package/src/identity/__tests__/oidc-discovery.test.ts +122 -0
- package/src/identity/__tests__/oidc-userinfo.test.ts +103 -0
- package/src/identity/__tests__/oidc-verifier.test.ts +173 -3
- package/src/identity/oidc-discovery.ts +84 -0
- package/src/identity/oidc-userinfo.ts +64 -0
- package/src/identity/oidc-verifier.ts +42 -45
- package/src/index.ts +19 -0
- package/src/pipeline/interceptors/auth.ts +13 -1
- package/src/pipeline/steps/__tests__/authorize-annotation-completeness.test.ts +184 -0
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pins the two registry points the identity-account domain adds
|
|
3
|
+
* (20260911.11, Q-IA-9), in the O5 driver-point shape the registry already
|
|
4
|
+
* enforces for organizationDirectory and listReadScope:
|
|
5
|
+
*
|
|
6
|
+
* - `drivers.identityAccountStore` — the store driver (the cloud serves
|
|
7
|
+
* the domain over cloud.iam_identity_account through it); single
|
|
8
|
+
* instance; absent = the OSS adapter over the generic Store;
|
|
9
|
+
* - `drivers.identityFederation` — the four federation RPC arms plus
|
|
10
|
+
* providerExists; single instance; absent = the four RPCs refuse
|
|
11
|
+
* UNIMPLEMENTED with the edition reason (the domain suite pins the
|
|
12
|
+
* refusal; this file pins the registry);
|
|
13
|
+
* - the slot `identity-account-provision:post-persist` is declared and
|
|
14
|
+
* empty with no extensions (registry.test.ts pins the full slot roster).
|
|
15
|
+
*/
|
|
16
|
+
import type { DescMessage } from "@bufbuild/protobuf";
|
|
17
|
+
import { describe, expect, it } from "vitest";
|
|
18
|
+
|
|
19
|
+
import type { IdentityAccountStore } from "../../domain/identityaccount/store.js";
|
|
20
|
+
import type { PipelineStep } from "../../pipeline/pipeline.js";
|
|
21
|
+
import { DECLARED_GATE_SLOTS } from "../gate-slots.js";
|
|
22
|
+
import type { GateSlotName } from "../gate-slots.js";
|
|
23
|
+
import type { IdentityFederation } from "../identity-federation.js";
|
|
24
|
+
import { resolveExtensions } from "../registry.js";
|
|
25
|
+
|
|
26
|
+
const unimplemented = () => Promise.reject(new Error("not exercised here"));
|
|
27
|
+
|
|
28
|
+
const store: IdentityAccountStore = {
|
|
29
|
+
save: unimplemented,
|
|
30
|
+
update: unimplemented,
|
|
31
|
+
deleteById: unimplemented,
|
|
32
|
+
findById: unimplemented,
|
|
33
|
+
findByIdpId: unimplemented,
|
|
34
|
+
findDirectByIdpId: unimplemented,
|
|
35
|
+
findDirectByEmail: unimplemented,
|
|
36
|
+
findByIds: unimplemented,
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const federation: IdentityFederation = {
|
|
40
|
+
createFederatedAccount: unimplemented,
|
|
41
|
+
updateFederatedAccount: unimplemented,
|
|
42
|
+
deprovisionFederatedAccount: unimplemented,
|
|
43
|
+
getByExternalSub: unimplemented,
|
|
44
|
+
providerExists: unimplemented,
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
describe("the identityAccountStore driver point", () => {
|
|
48
|
+
it("is undefined with no extensions — the OSS adapter serves", () => {
|
|
49
|
+
expect(resolveExtensions([]).drivers.identityAccountStore).toBeUndefined();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("carries the one registered instance through", () => {
|
|
53
|
+
const resolved = resolveExtensions([
|
|
54
|
+
{ name: "cloud-iam", drivers: { identityAccountStore: store } },
|
|
55
|
+
]);
|
|
56
|
+
expect(resolved.drivers.identityAccountStore).toBe(store);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("throws on a second registration, naming both units", () => {
|
|
60
|
+
expect(() =>
|
|
61
|
+
resolveExtensions([
|
|
62
|
+
{ name: "store-a", drivers: { identityAccountStore: store } },
|
|
63
|
+
{ name: "store-b", drivers: { identityAccountStore: store } },
|
|
64
|
+
]),
|
|
65
|
+
).toThrowError(
|
|
66
|
+
/extension 'store-b' registers an IdentityAccountStore, but 'store-a' already did/,
|
|
67
|
+
);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe("the identityFederation capability point", () => {
|
|
72
|
+
it("is undefined with no extensions — the four RPCs refuse", () => {
|
|
73
|
+
expect(resolveExtensions([]).drivers.identityFederation).toBeUndefined();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("carries the one registered instance through", () => {
|
|
77
|
+
const resolved = resolveExtensions([
|
|
78
|
+
{ name: "cloud-iam", drivers: { identityFederation: federation } },
|
|
79
|
+
]);
|
|
80
|
+
expect(resolved.drivers.identityFederation).toBe(federation);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("throws on a second registration, naming both units", () => {
|
|
84
|
+
expect(() =>
|
|
85
|
+
resolveExtensions([
|
|
86
|
+
{ name: "fed-a", drivers: { identityFederation: federation } },
|
|
87
|
+
{ name: "fed-b", drivers: { identityFederation: federation } },
|
|
88
|
+
]),
|
|
89
|
+
).toThrowError(
|
|
90
|
+
/extension 'fed-b' registers an IdentityFederation, but 'fed-a' already did/,
|
|
91
|
+
);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe("the identity-account-provision:post-persist slot", () => {
|
|
96
|
+
it("is declared vocabulary and empty with no extensions", () => {
|
|
97
|
+
expect(
|
|
98
|
+
DECLARED_GATE_SLOTS.has("identity-account-provision:post-persist"),
|
|
99
|
+
).toBe(true);
|
|
100
|
+
expect(
|
|
101
|
+
resolveExtensions([]).gateSteps.get(
|
|
102
|
+
"identity-account-provision:post-persist",
|
|
103
|
+
),
|
|
104
|
+
).toBeUndefined();
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("accepts a gate step from a unit and keeps unit order", () => {
|
|
108
|
+
const step = (name: string): PipelineStep<DescMessage> => ({
|
|
109
|
+
name,
|
|
110
|
+
execute: () => Promise.resolve(),
|
|
111
|
+
});
|
|
112
|
+
const resolved = resolveExtensions([
|
|
113
|
+
{
|
|
114
|
+
name: "cloud-iam",
|
|
115
|
+
gateSteps: new Map<GateSlotName, PipelineStep<DescMessage>[]>([
|
|
116
|
+
[
|
|
117
|
+
"identity-account-provision:post-persist",
|
|
118
|
+
[step("EnsurePersonalOrganization")],
|
|
119
|
+
],
|
|
120
|
+
]),
|
|
121
|
+
},
|
|
122
|
+
]);
|
|
123
|
+
expect(
|
|
124
|
+
resolved.gateSteps
|
|
125
|
+
.get("identity-account-provision:post-persist")
|
|
126
|
+
?.map((s) => s.name),
|
|
127
|
+
).toEqual(["EnsurePersonalOrganization"]);
|
|
128
|
+
});
|
|
129
|
+
});
|
|
@@ -477,6 +477,10 @@ describe("resolveExtensions — loud-fail throws (DD-006 §2b)", () => {
|
|
|
477
477
|
"agent-execution-create:pre-side-effect-gate",
|
|
478
478
|
"agent-execution-recover:pre-side-effect-gate",
|
|
479
479
|
"agent-execution-submit-approval:gate",
|
|
480
|
+
// 20260911.11 Q-IA-9: the seventh ratified slot — after the account
|
|
481
|
+
// persists inside provisionMyAccount, before the reply (the cloud's
|
|
482
|
+
// personal-organization ensure and backfill ride it).
|
|
483
|
+
"identity-account-provision:post-persist",
|
|
480
484
|
"org-create:post-persist",
|
|
481
485
|
"sandbox-acquisition:gate",
|
|
482
486
|
"session-create:pre-side-effect-gate",
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The tier-truthfulness invariant (editions program 20260911.03 DD-001;
|
|
3
|
+
* P0 review finding 4; landed with the first tier flip, 20260911.11
|
|
4
|
+
* Q-IA-6): a kind's `kind_meta.tier` states what the edition's server
|
|
5
|
+
* SERVES, not a plan. The SDK reads a tier as "available now"
|
|
6
|
+
* (sdk/typescript/src/resource-availability.ts), so a tier that runs ahead
|
|
7
|
+
* of the server turns hidden hooks into UNIMPLEMENTED errors, and a tier
|
|
8
|
+
* that lags hides a served kind.
|
|
9
|
+
*
|
|
10
|
+
* Proven against the EMPTY composition (no extension units — the
|
|
11
|
+
* open-source server as shipped): every `open_source` kind has a routed
|
|
12
|
+
* service and no `enterprise` or `cloud_only` kind does. Services are
|
|
13
|
+
* enumerated by replaying the composed `routes` closure into a recording
|
|
14
|
+
* router (compose.ts hands the same closure to both transports); a service
|
|
15
|
+
* maps to its kind by the package's kind segment, which equals
|
|
16
|
+
* `kind_meta.name` lowercased for every kind in the registry (checked at
|
|
17
|
+
* the gate against all 28 entries).
|
|
18
|
+
*
|
|
19
|
+
* Two exceptions were ruled at the P1 gate (20260911.04 T01_1_review.md,
|
|
20
|
+
* "Placements taken at the gate"). Under this check they take two shapes:
|
|
21
|
+
* - platform is a lie the ruling exempts by name: served by every
|
|
22
|
+
* edition (getServerInfo is is_public and every console reads it)
|
|
23
|
+
* while the tier names the platform#operator seat an Enterprise
|
|
24
|
+
* deployment sells. Its service lives at `ai.stigmer.platform.v1`, the
|
|
25
|
+
* one package where the kind IS the group;
|
|
26
|
+
* - api_resource_version is a record kind with no service in any
|
|
27
|
+
* edition; tiered cloud_only and unserved it tells no lie, so it needs
|
|
28
|
+
* no exemption — its own arm pins that it stays unserved.
|
|
29
|
+
* A third exemption can only join by the same recorded act.
|
|
30
|
+
*
|
|
31
|
+
* The mutation arms prove the check bites: the same function over a
|
|
32
|
+
* registry with one tier flipped reports the lie.
|
|
33
|
+
*/
|
|
34
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
35
|
+
import { tmpdir } from "node:os";
|
|
36
|
+
import path from "node:path";
|
|
37
|
+
|
|
38
|
+
import type { DescService } from "@bufbuild/protobuf";
|
|
39
|
+
import type { ConnectRouter } from "@connectrpc/connect";
|
|
40
|
+
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
41
|
+
|
|
42
|
+
import {
|
|
43
|
+
ApiResourceKind,
|
|
44
|
+
ResourceTier,
|
|
45
|
+
} from "@stigmer/protos/ai/stigmer/commons/apiresource/apiresourcekind/api_resource_kind_pb";
|
|
46
|
+
|
|
47
|
+
import { loadConfig } from "../../boot/config.js";
|
|
48
|
+
import { composeServer } from "../../boot/compose.js";
|
|
49
|
+
import type { ComposedServer } from "../../boot/compose.js";
|
|
50
|
+
import { createLogger } from "../../boot/logger.js";
|
|
51
|
+
import { getKindMeta } from "../../pipeline/apiresource-meta.js";
|
|
52
|
+
|
|
53
|
+
/** The registry as the check reads it: every kind with kind_meta, by tier. */
|
|
54
|
+
interface KindEntry {
|
|
55
|
+
readonly kind: ApiResourceKind;
|
|
56
|
+
readonly name: string;
|
|
57
|
+
readonly tier: ResourceTier;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The pre-ruled exemption, by name, with the ruling that exempts it. */
|
|
61
|
+
const RULED_EXCEPTIONS: ReadonlyMap<ApiResourceKind, string> = new Map([
|
|
62
|
+
[
|
|
63
|
+
ApiResourceKind.platform,
|
|
64
|
+
"served by every edition; the tier names the platform#operator seat (P1 gate, placements)",
|
|
65
|
+
],
|
|
66
|
+
]);
|
|
67
|
+
|
|
68
|
+
function registryEntries(): KindEntry[] {
|
|
69
|
+
return Object.values(ApiResourceKind)
|
|
70
|
+
.filter((value): value is ApiResourceKind => typeof value === "number")
|
|
71
|
+
.filter((kind) => kind !== ApiResourceKind.api_resource_kind_unknown)
|
|
72
|
+
.map((kind) => {
|
|
73
|
+
const meta = getKindMeta(kind);
|
|
74
|
+
return { kind, name: meta.name, tier: meta.tier };
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The kind segment of a service's fully-qualified name:
|
|
80
|
+
* `ai.stigmer.<group>.<kind>.v1.<Service>` → `<kind>`, and for the one
|
|
81
|
+
* package where the kind is the group, `ai.stigmer.<group>.v1.<Service>` →
|
|
82
|
+
* `<group>` (platform). Services outside the registry's shape (health,
|
|
83
|
+
* third-party, kind-less groups like search) yield a segment that matches
|
|
84
|
+
* no kind and fall out of the comparison.
|
|
85
|
+
*/
|
|
86
|
+
function kindSegmentOf(serviceTypeName: string): string | undefined {
|
|
87
|
+
const parts = serviceTypeName.split(".");
|
|
88
|
+
if (parts[0] !== "ai" || parts[1] !== "stigmer") return undefined;
|
|
89
|
+
if (parts.length === 5) return parts[2];
|
|
90
|
+
if (parts.length >= 6) return parts[3];
|
|
91
|
+
return undefined;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The invariant as a pure function. Returns one sentence per lie so the
|
|
96
|
+
* failure names the kind and the direction.
|
|
97
|
+
*/
|
|
98
|
+
function tierViolations(
|
|
99
|
+
entries: ReadonlyArray<KindEntry>,
|
|
100
|
+
servedKindSegments: ReadonlySet<string>,
|
|
101
|
+
exceptions: ReadonlyMap<ApiResourceKind, string>,
|
|
102
|
+
): string[] {
|
|
103
|
+
const violations: string[] = [];
|
|
104
|
+
for (const entry of entries) {
|
|
105
|
+
if (exceptions.has(entry.kind)) continue;
|
|
106
|
+
const served = servedKindSegments.has(entry.name.toLowerCase());
|
|
107
|
+
switch (entry.tier) {
|
|
108
|
+
case ResourceTier.open_source:
|
|
109
|
+
if (!served) {
|
|
110
|
+
violations.push(
|
|
111
|
+
`${entry.name} is tiered open_source but the empty composition routes no service for it`,
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
break;
|
|
115
|
+
case ResourceTier.enterprise:
|
|
116
|
+
case ResourceTier.cloud_only:
|
|
117
|
+
if (served) {
|
|
118
|
+
violations.push(
|
|
119
|
+
`${entry.name} is tiered ${ResourceTier[entry.tier]} but the empty composition serves it — the tier lags the server`,
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
break;
|
|
123
|
+
case ResourceTier.resource_tier_unspecified:
|
|
124
|
+
violations.push(
|
|
125
|
+
`${entry.name} has no tier — every kind_meta must declare one`,
|
|
126
|
+
);
|
|
127
|
+
break;
|
|
128
|
+
default: {
|
|
129
|
+
const exhaustive: never = entry.tier;
|
|
130
|
+
throw new Error(`unknown tier ${String(exhaustive)}`);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return violations;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Replays the composed routes into a recorder and returns the served kind segments. */
|
|
138
|
+
function servedKindSegments(
|
|
139
|
+
routes: (router: ConnectRouter) => void,
|
|
140
|
+
): Set<string> {
|
|
141
|
+
const served = new Set<string>();
|
|
142
|
+
const recorder = {
|
|
143
|
+
handlers: [],
|
|
144
|
+
service(desc: DescService) {
|
|
145
|
+
const segment = kindSegmentOf(desc.typeName);
|
|
146
|
+
if (segment !== undefined) served.add(segment);
|
|
147
|
+
return recorder;
|
|
148
|
+
},
|
|
149
|
+
rpc() {
|
|
150
|
+
return recorder;
|
|
151
|
+
},
|
|
152
|
+
};
|
|
153
|
+
routes(recorder as unknown as ConnectRouter);
|
|
154
|
+
return served;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
describe("tier truthfulness against the empty composition", () => {
|
|
158
|
+
let dir: string;
|
|
159
|
+
let server: ComposedServer;
|
|
160
|
+
|
|
161
|
+
beforeAll(async () => {
|
|
162
|
+
dir = mkdtempSync(path.join(tmpdir(), "tier-truthfulness-test-"));
|
|
163
|
+
server = await composeServer({
|
|
164
|
+
config: loadConfig({
|
|
165
|
+
STIGMER_MODEL_REGISTRY_REFRESH: "off",
|
|
166
|
+
TEMPORAL_HOST_PORT: "127.0.0.1:1",
|
|
167
|
+
DB_PATH: path.join(dir, "stigmer.db"),
|
|
168
|
+
STORAGE_PATH: path.join(dir, "storage"),
|
|
169
|
+
ARTIFACT_LOCAL_BASE_PATH: path.join(dir, "artifacts"),
|
|
170
|
+
}),
|
|
171
|
+
logger: createLogger({ level: "error", pretty: false, write: () => {} }),
|
|
172
|
+
portOverride: 0,
|
|
173
|
+
host: "127.0.0.1",
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
afterAll(async () => {
|
|
178
|
+
await server.shutdown();
|
|
179
|
+
rmSync(dir, { recursive: true, force: true });
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("every kind's tier states what the open-source server serves (two ruled exceptions)", () => {
|
|
183
|
+
const served = servedKindSegments(server.routes);
|
|
184
|
+
expect(served.size).toBeGreaterThan(10);
|
|
185
|
+
expect(tierViolations(registryEntries(), served, RULED_EXCEPTIONS)).toEqual(
|
|
186
|
+
[],
|
|
187
|
+
);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("the ruled exemption is exactly the lie the registry tells today, no fewer", () => {
|
|
191
|
+
// If platform stops being a lie (its tier becomes open_source), it must
|
|
192
|
+
// leave the exemption by a recorded act rather than linger as dead
|
|
193
|
+
// vocabulary.
|
|
194
|
+
const served = servedKindSegments(server.routes);
|
|
195
|
+
const withoutExceptions = tierViolations(
|
|
196
|
+
registryEntries(),
|
|
197
|
+
served,
|
|
198
|
+
new Map(),
|
|
199
|
+
);
|
|
200
|
+
const exceptionNames = [...RULED_EXCEPTIONS.keys()]
|
|
201
|
+
.map((kind) => getKindMeta(kind).name)
|
|
202
|
+
.sort();
|
|
203
|
+
const lyingNames = withoutExceptions
|
|
204
|
+
.map((sentence) => sentence.split(" ")[0] ?? "")
|
|
205
|
+
.sort();
|
|
206
|
+
expect(lyingNames).toEqual(exceptionNames);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it("api_resource_version stays a record kind with no service (the P1 gate's second placement)", () => {
|
|
210
|
+
const served = servedKindSegments(server.routes);
|
|
211
|
+
expect(served.has("apiresourceversion")).toBe(false);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it("identity_account is open_source and served — the flip and the serving PR are one change (Q-EC-2b)", () => {
|
|
215
|
+
const served = servedKindSegments(server.routes);
|
|
216
|
+
expect(getKindMeta(ApiResourceKind.identity_account).tier).toBe(
|
|
217
|
+
ResourceTier.open_source,
|
|
218
|
+
);
|
|
219
|
+
expect(served.has("identityaccount")).toBe(true);
|
|
220
|
+
});
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
describe("the check bites (mutation proofs over the same function)", () => {
|
|
224
|
+
const served = new Set(["agent", "apikey", "identityaccount"]);
|
|
225
|
+
const entries: KindEntry[] = [
|
|
226
|
+
{
|
|
227
|
+
kind: ApiResourceKind.agent,
|
|
228
|
+
name: "Agent",
|
|
229
|
+
tier: ResourceTier.open_source,
|
|
230
|
+
},
|
|
231
|
+
{
|
|
232
|
+
kind: ApiResourceKind.api_key,
|
|
233
|
+
name: "ApiKey",
|
|
234
|
+
tier: ResourceTier.open_source,
|
|
235
|
+
},
|
|
236
|
+
{
|
|
237
|
+
kind: ApiResourceKind.identity_account,
|
|
238
|
+
name: "IdentityAccount",
|
|
239
|
+
tier: ResourceTier.open_source,
|
|
240
|
+
},
|
|
241
|
+
{
|
|
242
|
+
kind: ApiResourceKind.invitation,
|
|
243
|
+
name: "Invitation",
|
|
244
|
+
tier: ResourceTier.cloud_only,
|
|
245
|
+
},
|
|
246
|
+
];
|
|
247
|
+
|
|
248
|
+
it("passes a truthful registry", () => {
|
|
249
|
+
expect(tierViolations(entries, served, new Map())).toEqual([]);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it("a tier that runs ahead of the server (open_source, unserved) is named", () => {
|
|
253
|
+
const ahead = entries.map((entry) =>
|
|
254
|
+
entry.kind === ApiResourceKind.invitation
|
|
255
|
+
? { ...entry, tier: ResourceTier.open_source }
|
|
256
|
+
: entry,
|
|
257
|
+
);
|
|
258
|
+
expect(tierViolations(ahead, served, new Map())).toEqual([
|
|
259
|
+
"Invitation is tiered open_source but the empty composition routes no service for it",
|
|
260
|
+
]);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it("a tier that lags the server (cloud_only, served) is named — reverting the flip fails here", () => {
|
|
264
|
+
const lagging = entries.map((entry) =>
|
|
265
|
+
entry.kind === ApiResourceKind.identity_account
|
|
266
|
+
? { ...entry, tier: ResourceTier.cloud_only }
|
|
267
|
+
: entry,
|
|
268
|
+
);
|
|
269
|
+
expect(tierViolations(lagging, served, new Map())).toEqual([
|
|
270
|
+
"IdentityAccount is tiered cloud_only but the empty composition serves it — the tier lags the server",
|
|
271
|
+
]);
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it("a kind with no tier is a lie of omission", () => {
|
|
275
|
+
const untiered = [
|
|
276
|
+
...entries,
|
|
277
|
+
{
|
|
278
|
+
kind: ApiResourceKind.memory,
|
|
279
|
+
name: "Memory",
|
|
280
|
+
tier: ResourceTier.resource_tier_unspecified,
|
|
281
|
+
},
|
|
282
|
+
];
|
|
283
|
+
expect(tierViolations(untiered, served, new Map())).toEqual([
|
|
284
|
+
"Memory has no tier — every kind_meta must declare one",
|
|
285
|
+
]);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it("an exception is honoured only by name", () => {
|
|
289
|
+
const lagging = entries.map((entry) =>
|
|
290
|
+
entry.kind === ApiResourceKind.identity_account
|
|
291
|
+
? { ...entry, tier: ResourceTier.cloud_only }
|
|
292
|
+
: entry,
|
|
293
|
+
);
|
|
294
|
+
const exceptions = new Map([
|
|
295
|
+
[ApiResourceKind.identity_account, "a test-only exemption"],
|
|
296
|
+
]);
|
|
297
|
+
expect(tierViolations(lagging, served, exceptions)).toEqual([]);
|
|
298
|
+
});
|
|
299
|
+
});
|
|
@@ -23,6 +23,9 @@
|
|
|
23
23
|
* - schedule-fire caller (who a schedule fire acts as) — landed with
|
|
24
24
|
* the stigmer-cloud#572 fix (the Java schedule-token mechanism's
|
|
25
25
|
* seam; ruled 2026-09-01)
|
|
26
|
+
* - identity-account store + identity federation (the first domain
|
|
27
|
+
* whose persistence is a PORT, and the four federated RPC arms only
|
|
28
|
+
* one edition serves) — landed with 20260911.11, gate ruling Q-IA-9
|
|
26
29
|
*
|
|
27
30
|
* Merge rules (enforced by resolveExtensions, DD-006 §2b): the two
|
|
28
31
|
* provider kinds are single-instance points — a second declaring unit is
|
|
@@ -36,11 +39,13 @@
|
|
|
36
39
|
*/
|
|
37
40
|
import type { ArtifactStorageDriverFactory } from "../artifactstorage/artifact-storage.js";
|
|
38
41
|
import type { ChannelRuntime } from "../domain/agentchannel/channel-runtime.js";
|
|
42
|
+
import type { IdentityAccountStore } from "../domain/identityaccount/store.js";
|
|
39
43
|
import type { SecretCodec } from "../encryption/codec.js";
|
|
40
44
|
import type { ModelCatalogProvider } from "../domain/workflow/registry/model-catalog-provider.js";
|
|
41
45
|
import type { VisitorErrorPolicy } from "../pipeline/interceptors/error-boundary.js";
|
|
42
46
|
import type { RunnerCredentialProvider } from "../runnerauth/runner-credential-provider.js";
|
|
43
47
|
import type { SandboxProvisionerFactory } from "../sandbox/provisioner.js";
|
|
48
|
+
import type { IdentityFederation } from "./identity-federation.js";
|
|
44
49
|
import type { ListReadScope } from "./list-read-scope.js";
|
|
45
50
|
import type { OrganizationDirectory } from "./organization-directory.js";
|
|
46
51
|
import type { ResourceAuthorizationLifecycle } from "./resource-authorization.js";
|
|
@@ -143,4 +148,25 @@ export interface ExtensionDrivers {
|
|
|
143
148
|
* the `internal` class — OSS behavior byte-identical.
|
|
144
149
|
*/
|
|
145
150
|
readonly scheduleFireCaller?: ScheduleFireCallerMint;
|
|
151
|
+
/**
|
|
152
|
+
* The identity-account store driver (20260911.11, Q-IA-9;
|
|
153
|
+
* single-instance point). The identity-account domain is the first
|
|
154
|
+
* whose persistence is a PORT rather than the generic Store: when
|
|
155
|
+
* composed, the domain's every read and write — the create path, the
|
|
156
|
+
* provisioner, the boot-time operator ensure and BOTH OSS verifiers'
|
|
157
|
+
* subject resolution — goes through this driver (the cloud serves the
|
|
158
|
+
* domain over its own `cloud.iam_identity_account` table this way).
|
|
159
|
+
* When absent, the OSS adapter over the generic Store installs at the
|
|
160
|
+
* compose.ts consumption site — OSS behavior byte-identical.
|
|
161
|
+
*/
|
|
162
|
+
readonly identityAccountStore?: IdentityAccountStore;
|
|
163
|
+
/**
|
|
164
|
+
* The identity-federation capability (20260911.11, Q-IA-9;
|
|
165
|
+
* single-instance point): the four federated-account RPC arms plus the
|
|
166
|
+
* IdP-exists probe their shared precondition rides. When composed, the
|
|
167
|
+
* identity-account controller dispatches to it after its own shared
|
|
168
|
+
* checks; when absent, the four RPCs refuse UNIMPLEMENTED with the
|
|
169
|
+
* edition reason — the organizationDirectory absent-method shape.
|
|
170
|
+
*/
|
|
171
|
+
readonly identityFederation?: IdentityFederation;
|
|
146
172
|
}
|
|
@@ -19,6 +19,21 @@
|
|
|
19
19
|
* splice — its capacity gates ride the two declared agent-execution
|
|
20
20
|
* slots, whose positions coincide exactly with the Java session gate.
|
|
21
21
|
*
|
|
22
|
+
* 20260911.11 (Q-IA-9) declares the seventh,
|
|
23
|
+
* `identity-account-provision:post-persist`: inside provisionMyAccount,
|
|
24
|
+
* after the provisioner has answered the caller's row — created on this
|
|
25
|
+
* call or found by the idempotent early return — and before the reply,
|
|
26
|
+
* with the caller RE-STAMPED as the account (the position-1 identity was
|
|
27
|
+
* idp-shaped because no row existed when the verifier ran). It fires on
|
|
28
|
+
* EVERY provisionMyAccount, not only the creating one: the cloud's
|
|
29
|
+
* personal-organization step backfills accounts that predate personal
|
|
30
|
+
* organizations on the idempotent path, which is exactly why a slot was
|
|
31
|
+
* chosen over the tuple lifecycle's onResourceCreated (never fired for a
|
|
32
|
+
* row that already exists). Never on the create RPC and never at the
|
|
33
|
+
* boot-time operator ensure, which take the create path, not this one.
|
|
34
|
+
* Non-transactional in the `org-create:post-persist` sense: a gate
|
|
35
|
+
* failure fails the request, the row survives, the next call heals.
|
|
36
|
+
*
|
|
22
37
|
* Two enforcement layers, deliberately redundant, both derived from the
|
|
23
38
|
* ONE literal tuple below (lockstep by construction):
|
|
24
39
|
* - GateSlotName (compile time): the union of declared slot literals.
|
|
@@ -55,6 +70,7 @@ export const GATE_SLOT_NAMES = [
|
|
|
55
70
|
"session-create:pre-side-effect-gate",
|
|
56
71
|
"org-create:post-persist",
|
|
57
72
|
"sandbox-acquisition:gate",
|
|
73
|
+
"identity-account-provision:post-persist",
|
|
58
74
|
] as const;
|
|
59
75
|
|
|
60
76
|
/** The declared slot-name union — a registration outside it fails tsc. */
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The identity-federation capability (20260911.11, Q-IA-9): the four
|
|
3
|
+
* federated-account RPC arms the identity-account controller dispatches
|
|
4
|
+
* to when a composed unit provides them, plus the identity-provider
|
|
5
|
+
* existence check their shared precondition rides. Single instance,
|
|
6
|
+
* registered as `drivers.identityFederation` (the organizationDirectory
|
|
7
|
+
* shape: a driver point that is byte-identical OSS behaviour when absent).
|
|
8
|
+
*
|
|
9
|
+
* Absent, the controller refuses the four RPCs UNIMPLEMENTED with the
|
|
10
|
+
* edition reason (domain/identityaccount/constants.ts) — never INTERNAL,
|
|
11
|
+
* the organization directory's absent-method precedent. Present, the
|
|
12
|
+
* controller still owns what is shared: the annotation's Authorize step
|
|
13
|
+
* on the organization, the ref-with-slug and org-match checks, and the
|
|
14
|
+
* IdP-exists refusal through `providerExists`; the arm receives the
|
|
15
|
+
* RESOLVED ref (org filled from the input when the ref left it empty)
|
|
16
|
+
* and the authenticated caller, and owns only what differs per edition —
|
|
17
|
+
* the natural-key lookups and writes on the federated rows.
|
|
18
|
+
*
|
|
19
|
+
* The federated natural key (provider ref + external subject) and its
|
|
20
|
+
* lookups stay on the unit's own store: the IdentityAccountStore port
|
|
21
|
+
* carries direct-account methods only, because a port does not carry
|
|
22
|
+
* methods only one edition calls.
|
|
23
|
+
*/
|
|
24
|
+
import type { ApiResourceReference } from "@stigmer/protos/ai/stigmer/commons/apiresource/io_pb";
|
|
25
|
+
import type { IdentityAccount } from "@stigmer/protos/ai/stigmer/iam/identityaccount/v1/api_pb";
|
|
26
|
+
import type {
|
|
27
|
+
CreateFederatedAccountInput,
|
|
28
|
+
DeprovisionFederatedAccountInput,
|
|
29
|
+
ExternalSubLookup,
|
|
30
|
+
UpdateFederatedAccountInput,
|
|
31
|
+
} from "@stigmer/protos/ai/stigmer/iam/identityaccount/v1/io_pb";
|
|
32
|
+
|
|
33
|
+
import type { CallerIdentity } from "./identity.js";
|
|
34
|
+
|
|
35
|
+
export interface IdentityFederation {
|
|
36
|
+
createFederatedAccount(
|
|
37
|
+
input: CreateFederatedAccountInput,
|
|
38
|
+
ref: ApiResourceReference,
|
|
39
|
+
caller: CallerIdentity,
|
|
40
|
+
): Promise<IdentityAccount>;
|
|
41
|
+
updateFederatedAccount(
|
|
42
|
+
input: UpdateFederatedAccountInput,
|
|
43
|
+
ref: ApiResourceReference,
|
|
44
|
+
caller: CallerIdentity,
|
|
45
|
+
): Promise<IdentityAccount>;
|
|
46
|
+
deprovisionFederatedAccount(
|
|
47
|
+
input: DeprovisionFederatedAccountInput,
|
|
48
|
+
ref: ApiResourceReference,
|
|
49
|
+
caller: CallerIdentity,
|
|
50
|
+
): Promise<IdentityAccount>;
|
|
51
|
+
getByExternalSub(
|
|
52
|
+
lookup: ExternalSubLookup,
|
|
53
|
+
ref: ApiResourceReference,
|
|
54
|
+
caller: CallerIdentity,
|
|
55
|
+
): Promise<IdentityAccount>;
|
|
56
|
+
/** Whether the IdentityProvider `org/slug` exists — the arms' shared precondition. */
|
|
57
|
+
providerExists(org: string, slug: string): Promise<boolean>;
|
|
58
|
+
}
|
|
@@ -30,7 +30,11 @@
|
|
|
30
30
|
* provider) and O6's sandbox provisioners are consumed at their
|
|
31
31
|
* compose.ts construction sites; the require-authentication posture
|
|
32
32
|
* (20260904.02) is consumed where compose.ts builds the serving chain's
|
|
33
|
-
* identity source, OR'd with the OIDC-issuer arm
|
|
33
|
+
* identity source, OR'd with the OIDC-issuer arm; the identity-account
|
|
34
|
+
* points (20260911.11) at the identity-accounts stage (the store driver,
|
|
35
|
+
* ahead of the boot-time operator ensure) and the routes stage (the
|
|
36
|
+
* federation capability and the provision slot's steps, into the
|
|
37
|
+
* identity-account controller).
|
|
34
38
|
*/
|
|
35
39
|
import type { DescMessage } from "@bufbuild/protobuf";
|
|
36
40
|
import type { ConnectRouter } from "@connectrpc/connect";
|
|
@@ -40,8 +44,10 @@ import { ServerEdition } from "@stigmer/protos/ai/stigmer/platform/v1/server_inf
|
|
|
40
44
|
import type { ArtifactStorageDriverFactory } from "../artifactstorage/artifact-storage.js";
|
|
41
45
|
import { BUILT_IN_STORAGE_TYPES } from "../artifactstorage/artifact-storage.js";
|
|
42
46
|
import type { ChannelRuntime } from "../domain/agentchannel/channel-runtime.js";
|
|
47
|
+
import type { IdentityAccountStore } from "../domain/identityaccount/store.js";
|
|
43
48
|
import type { SecretCodec } from "../encryption/codec.js";
|
|
44
49
|
import { V1_VERSION } from "../encryption/v1-codec.js";
|
|
50
|
+
import type { IdentityFederation } from "./identity-federation.js";
|
|
45
51
|
import type { ListReadScope } from "./list-read-scope.js";
|
|
46
52
|
import type { ModelCatalogProvider } from "../domain/workflow/registry/model-catalog-provider.js";
|
|
47
53
|
import type { VisitorErrorPolicy } from "../pipeline/interceptors/error-boundary.js";
|
|
@@ -233,6 +239,17 @@ export interface ResolvedExtensionDrivers {
|
|
|
233
239
|
* codec in and resolves the write version fail-fast.
|
|
234
240
|
*/
|
|
235
241
|
readonly secretCodecs: ReadonlyMap<string, SecretCodec>;
|
|
242
|
+
/**
|
|
243
|
+
* The 20260911.11 identity-account store driver — undefined = the
|
|
244
|
+
* compose.ts identity-accounts stage installs the OSS adapter over the
|
|
245
|
+
* generic Store, OSS behavior byte-identical.
|
|
246
|
+
*/
|
|
247
|
+
readonly identityAccountStore: IdentityAccountStore | undefined;
|
|
248
|
+
/**
|
|
249
|
+
* The 20260911.11 identity-federation capability — undefined = the
|
|
250
|
+
* four federated RPCs refuse UNIMPLEMENTED with the edition reason.
|
|
251
|
+
*/
|
|
252
|
+
readonly identityFederation: IdentityFederation | undefined;
|
|
236
253
|
}
|
|
237
254
|
|
|
238
255
|
/**
|
|
@@ -276,6 +293,10 @@ export function resolveExtensions(
|
|
|
276
293
|
let visitorErrorPolicyDeclaredBy: string | undefined;
|
|
277
294
|
let scheduleFireCaller: ScheduleFireCallerMint | undefined;
|
|
278
295
|
let scheduleFireCallerDeclaredBy: string | undefined;
|
|
296
|
+
let identityAccountStore: IdentityAccountStore | undefined;
|
|
297
|
+
let identityAccountStoreDeclaredBy: string | undefined;
|
|
298
|
+
let identityFederation: IdentityFederation | undefined;
|
|
299
|
+
let identityFederationDeclaredBy: string | undefined;
|
|
279
300
|
const artifactStorageDrivers = new Map<
|
|
280
301
|
string,
|
|
281
302
|
ArtifactStorageDriverFactory
|
|
@@ -417,6 +438,26 @@ export function resolveExtensions(
|
|
|
417
438
|
scheduleFireCallerDeclaredBy = unit.name;
|
|
418
439
|
}
|
|
419
440
|
|
|
441
|
+
if (unit.drivers?.identityAccountStore !== undefined) {
|
|
442
|
+
if (identityAccountStoreDeclaredBy !== undefined) {
|
|
443
|
+
throw new Error(
|
|
444
|
+
`extension '${unit.name}' registers an IdentityAccountStore, but '${identityAccountStoreDeclaredBy}' already did — exactly one may be composed`,
|
|
445
|
+
);
|
|
446
|
+
}
|
|
447
|
+
identityAccountStore = unit.drivers.identityAccountStore;
|
|
448
|
+
identityAccountStoreDeclaredBy = unit.name;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
if (unit.drivers?.identityFederation !== undefined) {
|
|
452
|
+
if (identityFederationDeclaredBy !== undefined) {
|
|
453
|
+
throw new Error(
|
|
454
|
+
`extension '${unit.name}' registers an IdentityFederation, but '${identityFederationDeclaredBy}' already did — exactly one may be composed`,
|
|
455
|
+
);
|
|
456
|
+
}
|
|
457
|
+
identityFederation = unit.drivers.identityFederation;
|
|
458
|
+
identityFederationDeclaredBy = unit.name;
|
|
459
|
+
}
|
|
460
|
+
|
|
420
461
|
if (unit.drivers?.artifactStorageDrivers !== undefined) {
|
|
421
462
|
for (const [name, factory] of unit.drivers.artifactStorageDrivers) {
|
|
422
463
|
if ((BUILT_IN_STORAGE_TYPES as ReadonlyArray<string>).includes(name)) {
|
|
@@ -537,6 +578,8 @@ export function resolveExtensions(
|
|
|
537
578
|
visitorErrorPolicy,
|
|
538
579
|
secretCodecs,
|
|
539
580
|
scheduleFireCaller,
|
|
581
|
+
identityAccountStore,
|
|
582
|
+
identityFederation,
|
|
540
583
|
},
|
|
541
584
|
services,
|
|
542
585
|
workers,
|