@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.
Files changed (116) hide show
  1. package/dist/boot/compose.d.ts +8 -1
  2. package/dist/boot/compose.d.ts.map +1 -1
  3. package/dist/boot/compose.js +98 -12
  4. package/dist/boot/compose.js.map +1 -1
  5. package/dist/domain/apikey/verifier.d.ts +8 -1
  6. package/dist/domain/apikey/verifier.d.ts.map +1 -1
  7. package/dist/domain/apikey/verifier.js +14 -2
  8. package/dist/domain/apikey/verifier.js.map +1 -1
  9. package/dist/domain/identityaccount/constants.d.ts +66 -0
  10. package/dist/domain/identityaccount/constants.d.ts.map +1 -0
  11. package/dist/domain/identityaccount/constants.js +153 -0
  12. package/dist/domain/identityaccount/constants.js.map +1 -0
  13. package/dist/domain/identityaccount/controller.d.ts +34 -0
  14. package/dist/domain/identityaccount/controller.d.ts.map +1 -0
  15. package/dist/domain/identityaccount/controller.js +342 -0
  16. package/dist/domain/identityaccount/controller.js.map +1 -0
  17. package/dist/domain/identityaccount/operator.d.ts +14 -0
  18. package/dist/domain/identityaccount/operator.d.ts.map +1 -0
  19. package/dist/domain/identityaccount/operator.js +67 -0
  20. package/dist/domain/identityaccount/operator.js.map +1 -0
  21. package/dist/domain/identityaccount/provisioning.d.ts +52 -0
  22. package/dist/domain/identityaccount/provisioning.d.ts.map +1 -0
  23. package/dist/domain/identityaccount/provisioning.js +114 -0
  24. package/dist/domain/identityaccount/provisioning.js.map +1 -0
  25. package/dist/domain/identityaccount/resolve.d.ts +36 -0
  26. package/dist/domain/identityaccount/resolve.d.ts.map +1 -0
  27. package/dist/domain/identityaccount/resolve.js +12 -0
  28. package/dist/domain/identityaccount/resolve.js.map +1 -0
  29. package/dist/domain/identityaccount/resource-store.d.ts +4 -0
  30. package/dist/domain/identityaccount/resource-store.d.ts.map +1 -0
  31. package/dist/domain/identityaccount/resource-store.js +131 -0
  32. package/dist/domain/identityaccount/resource-store.js.map +1 -0
  33. package/dist/domain/identityaccount/steps.d.ts +79 -0
  34. package/dist/domain/identityaccount/steps.d.ts.map +1 -0
  35. package/dist/domain/identityaccount/steps.js +291 -0
  36. package/dist/domain/identityaccount/steps.js.map +1 -0
  37. package/dist/domain/identityaccount/store-contract.d.ts +22 -0
  38. package/dist/domain/identityaccount/store-contract.d.ts.map +1 -0
  39. package/dist/domain/identityaccount/store-contract.js +307 -0
  40. package/dist/domain/identityaccount/store-contract.js.map +1 -0
  41. package/dist/domain/identityaccount/store.d.ts +66 -0
  42. package/dist/domain/identityaccount/store.d.ts.map +1 -0
  43. package/dist/domain/identityaccount/store.js +13 -0
  44. package/dist/domain/identityaccount/store.js.map +1 -0
  45. package/dist/extensions/drivers.d.ts +26 -0
  46. package/dist/extensions/drivers.d.ts.map +1 -1
  47. package/dist/extensions/gate-slots.d.ts +16 -1
  48. package/dist/extensions/gate-slots.d.ts.map +1 -1
  49. package/dist/extensions/gate-slots.js +1 -0
  50. package/dist/extensions/gate-slots.js.map +1 -1
  51. package/dist/extensions/identity-federation.d.ts +36 -0
  52. package/dist/extensions/identity-federation.d.ts.map +1 -0
  53. package/dist/extensions/identity-federation.js +2 -0
  54. package/dist/extensions/identity-federation.js.map +1 -0
  55. package/dist/extensions/registry.d.ts +18 -1
  56. package/dist/extensions/registry.d.ts.map +1 -1
  57. package/dist/extensions/registry.js +20 -0
  58. package/dist/extensions/registry.js.map +1 -1
  59. package/dist/identity/oidc-discovery.d.ts +36 -0
  60. package/dist/identity/oidc-discovery.d.ts.map +1 -0
  61. package/dist/identity/oidc-discovery.js +60 -0
  62. package/dist/identity/oidc-discovery.js.map +1 -0
  63. package/dist/identity/oidc-userinfo.d.ts +30 -0
  64. package/dist/identity/oidc-userinfo.d.ts.map +1 -0
  65. package/dist/identity/oidc-userinfo.js +28 -0
  66. package/dist/identity/oidc-userinfo.js.map +1 -0
  67. package/dist/identity/oidc-verifier.d.ts +6 -0
  68. package/dist/identity/oidc-verifier.d.ts.map +1 -1
  69. package/dist/identity/oidc-verifier.js +35 -34
  70. package/dist/identity/oidc-verifier.js.map +1 -1
  71. package/dist/index.d.ts +7 -0
  72. package/dist/index.d.ts.map +1 -1
  73. package/dist/index.js +3 -0
  74. package/dist/index.js.map +1 -1
  75. package/dist/pipeline/interceptors/auth.d.ts +10 -0
  76. package/dist/pipeline/interceptors/auth.d.ts.map +1 -1
  77. package/dist/pipeline/interceptors/auth.js +9 -1
  78. package/dist/pipeline/interceptors/auth.js.map +1 -1
  79. package/package.json +4 -4
  80. package/src/boot/__tests__/auth-enabled-composition.test.ts +226 -2
  81. package/src/boot/compose.ts +118 -13
  82. package/src/domain/apikey/__tests__/verifier.test.ts +146 -22
  83. package/src/domain/apikey/verifier.ts +24 -2
  84. package/src/domain/identityaccount/__tests__/constants.test.ts +155 -0
  85. package/src/domain/identityaccount/__tests__/identityaccount.test.ts +484 -0
  86. package/src/domain/identityaccount/__tests__/operator.test.ts +180 -0
  87. package/src/domain/identityaccount/__tests__/provisioning.test.ts +281 -0
  88. package/src/domain/identityaccount/__tests__/resolve.test.ts +114 -0
  89. package/src/domain/identityaccount/__tests__/resource-store.test.ts +216 -0
  90. package/src/domain/identityaccount/__tests__/support.ts +58 -0
  91. package/src/domain/identityaccount/constants.ts +178 -0
  92. package/src/domain/identityaccount/controller.ts +667 -0
  93. package/src/domain/identityaccount/operator.ts +94 -0
  94. package/src/domain/identityaccount/provisioning.ts +182 -0
  95. package/src/domain/identityaccount/resolve.ts +52 -0
  96. package/src/domain/identityaccount/resource-store.ts +157 -0
  97. package/src/domain/identityaccount/steps.ts +377 -0
  98. package/src/domain/identityaccount/store-contract.ts +471 -0
  99. package/src/domain/identityaccount/store.ts +70 -0
  100. package/src/extensions/__tests__/identity-account-composed.test.ts +803 -0
  101. package/src/extensions/__tests__/identity-account-points.test.ts +129 -0
  102. package/src/extensions/__tests__/registry.test.ts +4 -0
  103. package/src/extensions/__tests__/tier-truthfulness.test.ts +299 -0
  104. package/src/extensions/drivers.ts +26 -0
  105. package/src/extensions/gate-slots.ts +16 -0
  106. package/src/extensions/identity-federation.ts +58 -0
  107. package/src/extensions/registry.ts +44 -1
  108. package/src/identity/__tests__/oidc-discovery.test.ts +122 -0
  109. package/src/identity/__tests__/oidc-userinfo.test.ts +103 -0
  110. package/src/identity/__tests__/oidc-verifier.test.ts +173 -3
  111. package/src/identity/oidc-discovery.ts +84 -0
  112. package/src/identity/oidc-userinfo.ts +64 -0
  113. package/src/identity/oidc-verifier.ts +42 -45
  114. package/src/index.ts +19 -0
  115. package/src/pipeline/interceptors/auth.ts +13 -1
  116. package/src/pipeline/steps/__tests__/authorize-annotation-completeness.test.ts +184 -0
@@ -18,27 +18,43 @@
18
18
  * - everything else → "invalid token"
19
19
  *
20
20
  * Discovery and key handling: the issuer's /.well-known/openid-configuration
21
- * is fetched once (memoized on success, retried on the next request after
22
- * a failure — a flaky IdP at boot must not permanently brick the lane),
23
- * its `issuer` field must match the configured issuer exactly (RFC 8414
24
- * §3.3), and the JWKS rides jose's createRemoteJWKSet (cached, rotation-
25
- * aware). Discovery/JWKS OUTAGES are infrastructure faults — thrown as
21
+ * is read through the shared oidc-discovery module the composition root
22
+ * builds once for every lane (20260911.11 A8 — the userinfo client reads
23
+ * the same document, so one issuer is discovered once and validated the
24
+ * same way wherever it is consumed: `issuer` must match exactly, RFC 8414
25
+ * §3.3; `jwks_uri` required). The module memoizes on success only, so a
26
+ * flaky IdP at boot never permanently bricks the lane. The JWKS client
27
+ * (jose's createRemoteJWKSet — cached, rotation-aware) is built ONCE per
28
+ * verifier from the discovered location and kept: discovery hands back a
29
+ * URI, and a client rebuilt per request would refetch the keys per
30
+ * request. Discovery/JWKS OUTAGES are infrastructure faults — thrown as
26
31
  * plain errors so the chassis maps them to INTERNAL, never a credential
27
32
  * rejection (the DD-007 unavailable doctrine).
28
33
  *
29
- * Identity: identityId = `sub` (rejected as invalid when absent), issuer =
30
- * the configured issuer, email/displayName from the standard `email`/`name`
31
- * claims when present (the DD-007 Q5 addendum added the fields for exactly
32
- * these claims).
34
+ * Identity (20260911.11 Q-IA-2, A1 — the cloud's direct-login posture):
35
+ * after the token verifies, `sub` (rejected as invalid when absent) is
36
+ * resolved through the identity-account domain (identityIdForSubject):
37
+ * a subject with a direct account stamps the ACCOUNT id; a subject
38
+ * without one is admitted idp-shaped (identityId = sub) so whoAmI can
39
+ * answer NOT_FOUND and provisionMyAccount can run. One primary-key read
40
+ * per request, no cache; a store fault is an infrastructure fault. The
41
+ * issuer is the configured issuer; email/displayName come from the
42
+ * standard `email`/`name` claims when present (the DD-007 Q5 addendum
43
+ * added the fields for exactly these claims), whichever way the subject
44
+ * resolved. Claim-or-pass runs before any of this: a non-JWT token
45
+ * passes with no network.
33
46
  */
34
47
  import { createRemoteJWKSet, jwtVerify, errors as joseErrors } from "jose";
35
48
  import type { JWTPayload } from "jose";
36
49
  import { Code, ConnectError } from "@connectrpc/connect";
37
50
 
51
+ import { identityIdForSubject } from "../domain/identityaccount/resolve.js";
52
+ import type { AccountsBySubject } from "../domain/identityaccount/resolve.js";
38
53
  import type {
39
54
  CallerIdentity,
40
55
  IdentityVerifier,
41
56
  } from "../extensions/identity.js";
57
+ import type { IssuerDiscovery } from "./oidc-discovery.js";
42
58
 
43
59
  /** Java classifyAuthError arms — byte-pinned cross-edition copy. */
44
60
  export const TOKEN_EXPIRED_MESSAGE = "token has expired";
@@ -52,42 +68,28 @@ export interface OidcVerifierConfig {
52
68
  readonly issuer: string;
53
69
  /** The audience access tokens must carry. */
54
70
  readonly audience: string;
71
+ /** The identity-account domain's subject lookup — REQUIRED: what identityId means depends on it. */
72
+ readonly accounts: AccountsBySubject;
73
+ /** The composition root's one discovery for every lane (A8). */
74
+ readonly discovery: IssuerDiscovery;
55
75
  }
56
76
 
57
- interface DiscoveredIssuer {
58
- readonly jwks: ReturnType<typeof createRemoteJWKSet>;
59
- }
77
+ type RemoteJwks = ReturnType<typeof createRemoteJWKSet>;
60
78
 
61
79
  export function newOidcIdentityVerifier(
62
80
  config: OidcVerifierConfig,
63
81
  ): IdentityVerifier {
64
- let discovered: DiscoveredIssuer | undefined;
82
+ // Set on the first successful discovery only: a failed discovery leaves
83
+ // it unset so the next request retries (the module caches no failure).
84
+ let jwks: RemoteJwks | undefined;
65
85
 
66
- async function discover(): Promise<DiscoveredIssuer> {
67
- if (discovered !== undefined) {
68
- return discovered;
69
- }
70
- const url = discoveryUrl(config.issuer);
71
- const response = await fetch(url);
72
- if (!response.ok) {
73
- throw new Error(
74
- `OIDC discovery failed: ${url} answered HTTP ${response.status}`,
75
- );
76
- }
77
- const document = (await response.json()) as {
78
- issuer?: string;
79
- jwks_uri?: string;
80
- };
81
- if (document.issuer !== config.issuer) {
82
- throw new Error(
83
- `OIDC discovery failed: document issuer '${document.issuer ?? ""}' does not match configured issuer '${config.issuer}'`,
84
- );
86
+ async function jwksFor(): Promise<RemoteJwks> {
87
+ if (jwks !== undefined) {
88
+ return jwks;
85
89
  }
86
- if (typeof document.jwks_uri !== "string" || document.jwks_uri === "") {
87
- throw new Error(`OIDC discovery failed: ${url} carries no jwks_uri`);
88
- }
89
- discovered = { jwks: createRemoteJWKSet(new URL(document.jwks_uri)) };
90
- return discovered;
90
+ const { jwksUri } = await config.discovery.discover(config.issuer);
91
+ jwks = createRemoteJWKSet(new URL(jwksUri));
92
+ return jwks;
91
93
  }
92
94
 
93
95
  return {
@@ -96,10 +98,10 @@ export function newOidcIdentityVerifier(
96
98
  if (!isJwtShaped(token)) {
97
99
  return null;
98
100
  }
99
- const issuer = await discover();
101
+ const keys = await jwksFor();
100
102
  let payload: JWTPayload;
101
103
  try {
102
- ({ payload } = await jwtVerify(token, issuer.jwks, {
104
+ ({ payload } = await jwtVerify(token, keys, {
103
105
  issuer: config.issuer,
104
106
  audience: config.audience,
105
107
  }));
@@ -110,7 +112,7 @@ export function newOidcIdentityVerifier(
110
112
  throw new ConnectError(INVALID_TOKEN_MESSAGE, Code.Unauthenticated);
111
113
  }
112
114
  return {
113
- identityId: payload.sub,
115
+ identityId: await identityIdForSubject(config.accounts, payload.sub),
114
116
  callerClass: "user",
115
117
  issuer: config.issuer,
116
118
  rawToken: token,
@@ -125,11 +127,6 @@ export function newOidcIdentityVerifier(
125
127
  };
126
128
  }
127
129
 
128
- /** issuer + /.well-known/openid-configuration (trailing-slash tolerant). */
129
- function discoveryUrl(issuer: string): string {
130
- return `${issuer.replace(/\/$/, "")}/.well-known/openid-configuration`;
131
- }
132
-
133
130
  /** Three non-empty dot-separated segments — the JWS compact shape. */
134
131
  function isJwtShaped(token: string): boolean {
135
132
  const segments = token.split(".");
package/src/index.ts CHANGED
@@ -109,6 +109,25 @@ export type {
109
109
  } from "./extensions/resource-authorization.js";
110
110
  export type { OrganizationDirectory } from "./extensions/organization-directory.js";
111
111
  export { ALL_ORGANIZATIONS } from "./extensions/organization-directory.js";
112
+ // The 20260911.11 identity-account seams (Q-IA-9): the store PORT a
113
+ // composition drives the domain through (drivers.identityAccountStore;
114
+ // a driver throws DuplicateAccountError for a held id), the federation
115
+ // capability (drivers.identityFederation), and the ONE subject →
116
+ // identityId rule both OSS verifiers already apply, exported so a
117
+ // composition's own verifier converges on it instead of restating it.
118
+ export type { IdentityAccountStore } from "./domain/identityaccount/store.js";
119
+ export { DuplicateAccountError } from "./domain/identityaccount/store.js";
120
+ // The port's contract as a vitest-free kit: a driver's own test iterates
121
+ // the same cases the OSS adapter passes, so the port is proven per
122
+ // driver, never restated per repository.
123
+ export type {
124
+ IdentityAccountStoreContractCase,
125
+ IdentityAccountStoreContractFixture,
126
+ } from "./domain/identityaccount/store-contract.js";
127
+ export { identityAccountStoreContract } from "./domain/identityaccount/store-contract.js";
128
+ export type { IdentityFederation } from "./extensions/identity-federation.js";
129
+ export type { AccountsBySubject } from "./domain/identityaccount/resolve.js";
130
+ export { identityIdForSubject } from "./domain/identityaccount/resolve.js";
112
131
  export type {
113
132
  ListReadScope,
114
133
  ListEntryMeta,
@@ -172,7 +172,19 @@ export function callerIdentityOf(ctx: HandlerContext): CallerIdentity {
172
172
  * operator seam remains process-global.
173
173
  */
174
174
  export function trustedLocalIdentity(): CallerIdentity {
175
- const operator = operatorIdentitySnapshot();
175
+ return trustedLocalIdentityFor(operatorIdentitySnapshot());
176
+ }
177
+
178
+ /**
179
+ * The trusted-local identity for a given operator — the one construction
180
+ * of that principal, shared by the per-request stamp above and by the
181
+ * boot-time operator account (domain/identityaccount/operator.ts), so the
182
+ * row the server ensures and the caller it stamps can never disagree.
183
+ */
184
+ export function trustedLocalIdentityFor(operator: {
185
+ readonly email: string;
186
+ readonly displayName: string;
187
+ }): CallerIdentity {
176
188
  if (operator.email === "") {
177
189
  return {
178
190
  identityId: "system",
@@ -0,0 +1,184 @@
1
+ /**
2
+ * The annotation-completeness invariant for the Authorize step (stigmer#1073;
3
+ * found on the 20260911.11 cloud readout, 2026-09-13): an `rpc.config`
4
+ * annotation that names a resource KIND must also name where the resource
5
+ * ID comes from (`field_path` or `resource_id`). A kind without an id is a
6
+ * check against `<kind>:` with an empty id — the step resolves it to "" by
7
+ * doctrine (resolution never throws, see authorize.ts) and an enforcing
8
+ * authorizer refuses it for EVERY caller, while the open-source permissive
9
+ * default allows it, so no `local*` conformance target can see the gap.
10
+ * `getActorInfo` shipped that way: green on every OSS gate, denied on the
11
+ * cloud composition the moment the annotation-driven step replaced the
12
+ * cloud's hand-written handler (which had passed `id.value` by hand).
13
+ *
14
+ * Scope is every method the open-source server SERVES — the empty
15
+ * composition's `routes` replayed into a recording router, the
16
+ * tier-truthfulness precedent — because that is exactly the set the OSS
17
+ * Authorize step governs; a composition's own services are its own
18
+ * `direct-handler-authorization` conformance suite's business. Two arms are
19
+ * exempt by construction: `is_public` and `is_skip_authorization` methods
20
+ * never reach the annotation's resolution (the skip lanes resolve their
21
+ * target in the handler through `authorizeDirect`'s override). A config
22
+ * that names NO kind (static or by path) is outside this invariant: the
23
+ * check is then about the kind, not the id, and today no OSS-served method
24
+ * is shaped that way (the IamPolicy RPCs that are belong to
25
+ * `sp.oss-organization-roles`).
26
+ *
27
+ * The mutation arms prove the check bites over the same function.
28
+ */
29
+ import { mkdtempSync, rmSync } from "node:fs";
30
+ import { tmpdir } from "node:os";
31
+ import path from "node:path";
32
+
33
+ import { getOption, hasOption } from "@bufbuild/protobuf";
34
+ import type { DescService } from "@bufbuild/protobuf";
35
+ import type { ConnectRouter } from "@connectrpc/connect";
36
+ import { afterAll, beforeAll, describe, expect, it } from "vitest";
37
+
38
+ import { ApiResourceKind } from "@stigmer/protos/ai/stigmer/commons/apiresource/apiresourcekind/api_resource_kind_pb";
39
+ import {
40
+ config as rpcAuthorizationConfig,
41
+ is_public,
42
+ is_skip_authorization,
43
+ } from "@stigmer/protos/ai/stigmer/commons/rpc/method_options_pb";
44
+
45
+ import { loadConfig } from "../../../boot/config.js";
46
+ import { composeServer } from "../../../boot/compose.js";
47
+ import type { ComposedServer } from "../../../boot/compose.js";
48
+ import { createLogger } from "../../../boot/logger.js";
49
+
50
+ /** One annotated method as the invariant reads it. */
51
+ interface AnnotationTarget {
52
+ readonly method: string;
53
+ readonly namesKind: boolean;
54
+ readonly namesId: boolean;
55
+ readonly skipsResolution: boolean;
56
+ }
57
+
58
+ /** Replays the composed routes into a recorder and returns the served services. */
59
+ function servedServices(
60
+ routes: (router: ConnectRouter) => void,
61
+ ): DescService[] {
62
+ const served: DescService[] = [];
63
+ const recorder = {
64
+ handlers: [],
65
+ service(desc: DescService) {
66
+ served.push(desc);
67
+ return recorder;
68
+ },
69
+ rpc() {
70
+ return recorder;
71
+ },
72
+ };
73
+ routes(recorder as unknown as ConnectRouter);
74
+ return served;
75
+ }
76
+
77
+ /** Every `rpc.config`-annotated method of the given services, read once. */
78
+ function annotationTargets(services: DescService[]): AnnotationTarget[] {
79
+ const targets: AnnotationTarget[] = [];
80
+ for (const service of services) {
81
+ for (const method of service.methods) {
82
+ if (!hasOption(method, rpcAuthorizationConfig)) continue;
83
+ const config = getOption(method, rpcAuthorizationConfig);
84
+ targets.push({
85
+ method: `${service.typeName}/${method.name}`,
86
+ namesKind:
87
+ config.resourceKind !== ApiResourceKind.api_resource_kind_unknown ||
88
+ config.resourceKindPath !== "",
89
+ namesId: config.fieldPath !== "" || config.resourceId !== "",
90
+ skipsResolution:
91
+ getOption(method, is_public) ||
92
+ getOption(method, is_skip_authorization),
93
+ });
94
+ }
95
+ }
96
+ return targets;
97
+ }
98
+
99
+ /** The methods whose annotation names a kind but no id, one sentence each. */
100
+ function unresolvableTargets(targets: AnnotationTarget[]): string[] {
101
+ return targets
102
+ .filter((t) => !t.skipsResolution && t.namesKind && !t.namesId)
103
+ .map(
104
+ (t) =>
105
+ `${t.method} names a resource kind but neither field_path nor resource_id — the Authorize step would check an empty id`,
106
+ );
107
+ }
108
+
109
+ describe("authorization annotations name where the resource id comes from", () => {
110
+ let dir: string;
111
+ let server: ComposedServer;
112
+
113
+ beforeAll(async () => {
114
+ dir = mkdtempSync(path.join(tmpdir(), "authorize-annotations-test-"));
115
+ server = await composeServer({
116
+ config: loadConfig({
117
+ STIGMER_MODEL_REGISTRY_REFRESH: "off",
118
+ TEMPORAL_HOST_PORT: "127.0.0.1:1",
119
+ DB_PATH: path.join(dir, "stigmer.db"),
120
+ STORAGE_PATH: path.join(dir, "storage"),
121
+ ARTIFACT_LOCAL_BASE_PATH: path.join(dir, "artifacts"),
122
+ }),
123
+ logger: createLogger({ level: "error", pretty: false, write: () => {} }),
124
+ portOverride: 0,
125
+ host: "127.0.0.1",
126
+ });
127
+ });
128
+
129
+ afterAll(async () => {
130
+ await server.shutdown();
131
+ rmSync(dir, { recursive: true, force: true });
132
+ });
133
+
134
+ it("every served method that names a kind also names its id (field_path or resource_id)", () => {
135
+ const targets = annotationTargets(servedServices(server.routes));
136
+ expect(targets.length).toBeGreaterThan(100);
137
+ expect(unresolvableTargets(targets)).toEqual([]);
138
+ });
139
+
140
+ it("getActorInfo is annotated exactly like get — the arm stigmer#1073 was missing", () => {
141
+ const targets = annotationTargets(servedServices(server.routes));
142
+ const byName = new Map(targets.map((t) => [t.method, t]));
143
+ const get = byName.get(
144
+ "ai.stigmer.iam.identityaccount.v1.IdentityAccountQueryController/get",
145
+ );
146
+ const actor = byName.get(
147
+ "ai.stigmer.iam.identityaccount.v1.IdentityAccountQueryController/getActorInfo",
148
+ );
149
+ expect(get?.namesId, "get names its id").toBe(true);
150
+ expect(actor?.namesId, "getActorInfo names its id").toBe(true);
151
+ });
152
+ });
153
+
154
+ describe("the check bites (mutation proofs over the same function)", () => {
155
+ const shaped = (overrides: Partial<AnnotationTarget>): AnnotationTarget => ({
156
+ method: "x.v1.Query/probe",
157
+ namesKind: true,
158
+ namesId: true,
159
+ skipsResolution: false,
160
+ ...overrides,
161
+ });
162
+
163
+ it("a kind with no id is reported, by method name", () => {
164
+ expect(unresolvableTargets([shaped({ namesId: false })])).toEqual([
165
+ "x.v1.Query/probe names a resource kind but neither field_path nor resource_id — the Authorize step would check an empty id",
166
+ ]);
167
+ });
168
+
169
+ it("a kind with an id is not", () => {
170
+ expect(unresolvableTargets([shaped({})])).toEqual([]);
171
+ });
172
+
173
+ it("a skip or public method is exempt — its target is the handler's, not the annotation's", () => {
174
+ expect(
175
+ unresolvableTargets([shaped({ namesId: false, skipsResolution: true })]),
176
+ ).toEqual([]);
177
+ });
178
+
179
+ it("a config that names no kind is outside this invariant", () => {
180
+ expect(
181
+ unresolvableTargets([shaped({ namesKind: false, namesId: false })]),
182
+ ).toEqual([]);
183
+ });
184
+ });