@stigmer/server 3.14.1 → 3.15.1

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 (129) 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 +121 -14
  4. package/dist/boot/compose.js.map +1 -1
  5. package/dist/boot/config.d.ts +14 -0
  6. package/dist/boot/config.d.ts.map +1 -1
  7. package/dist/boot/config.js +1 -0
  8. package/dist/boot/config.js.map +1 -1
  9. package/dist/domain/apikey/verifier.d.ts +8 -1
  10. package/dist/domain/apikey/verifier.d.ts.map +1 -1
  11. package/dist/domain/apikey/verifier.js +14 -2
  12. package/dist/domain/apikey/verifier.js.map +1 -1
  13. package/dist/domain/identityaccount/constants.d.ts +66 -0
  14. package/dist/domain/identityaccount/constants.d.ts.map +1 -0
  15. package/dist/domain/identityaccount/constants.js +153 -0
  16. package/dist/domain/identityaccount/constants.js.map +1 -0
  17. package/dist/domain/identityaccount/controller.d.ts +34 -0
  18. package/dist/domain/identityaccount/controller.d.ts.map +1 -0
  19. package/dist/domain/identityaccount/controller.js +342 -0
  20. package/dist/domain/identityaccount/controller.js.map +1 -0
  21. package/dist/domain/identityaccount/operator.d.ts +14 -0
  22. package/dist/domain/identityaccount/operator.d.ts.map +1 -0
  23. package/dist/domain/identityaccount/operator.js +67 -0
  24. package/dist/domain/identityaccount/operator.js.map +1 -0
  25. package/dist/domain/identityaccount/provisioning.d.ts +52 -0
  26. package/dist/domain/identityaccount/provisioning.d.ts.map +1 -0
  27. package/dist/domain/identityaccount/provisioning.js +114 -0
  28. package/dist/domain/identityaccount/provisioning.js.map +1 -0
  29. package/dist/domain/identityaccount/resolve.d.ts +36 -0
  30. package/dist/domain/identityaccount/resolve.d.ts.map +1 -0
  31. package/dist/domain/identityaccount/resolve.js +12 -0
  32. package/dist/domain/identityaccount/resolve.js.map +1 -0
  33. package/dist/domain/identityaccount/resource-store.d.ts +4 -0
  34. package/dist/domain/identityaccount/resource-store.d.ts.map +1 -0
  35. package/dist/domain/identityaccount/resource-store.js +131 -0
  36. package/dist/domain/identityaccount/resource-store.js.map +1 -0
  37. package/dist/domain/identityaccount/steps.d.ts +79 -0
  38. package/dist/domain/identityaccount/steps.d.ts.map +1 -0
  39. package/dist/domain/identityaccount/steps.js +291 -0
  40. package/dist/domain/identityaccount/steps.js.map +1 -0
  41. package/dist/domain/identityaccount/store-contract.d.ts +22 -0
  42. package/dist/domain/identityaccount/store-contract.d.ts.map +1 -0
  43. package/dist/domain/identityaccount/store-contract.js +307 -0
  44. package/dist/domain/identityaccount/store-contract.js.map +1 -0
  45. package/dist/domain/identityaccount/store.d.ts +66 -0
  46. package/dist/domain/identityaccount/store.d.ts.map +1 -0
  47. package/dist/domain/identityaccount/store.js +13 -0
  48. package/dist/domain/identityaccount/store.js.map +1 -0
  49. package/dist/extensions/drivers.d.ts +26 -0
  50. package/dist/extensions/drivers.d.ts.map +1 -1
  51. package/dist/extensions/gate-slots.d.ts +16 -1
  52. package/dist/extensions/gate-slots.d.ts.map +1 -1
  53. package/dist/extensions/gate-slots.js +1 -0
  54. package/dist/extensions/gate-slots.js.map +1 -1
  55. package/dist/extensions/identity-federation.d.ts +36 -0
  56. package/dist/extensions/identity-federation.d.ts.map +1 -0
  57. package/dist/extensions/identity-federation.js +2 -0
  58. package/dist/extensions/identity-federation.js.map +1 -0
  59. package/dist/extensions/registry.d.ts +18 -1
  60. package/dist/extensions/registry.d.ts.map +1 -1
  61. package/dist/extensions/registry.js +20 -0
  62. package/dist/extensions/registry.js.map +1 -1
  63. package/dist/identity/oidc-discovery.d.ts +36 -0
  64. package/dist/identity/oidc-discovery.d.ts.map +1 -0
  65. package/dist/identity/oidc-discovery.js +60 -0
  66. package/dist/identity/oidc-discovery.js.map +1 -0
  67. package/dist/identity/oidc-userinfo.d.ts +30 -0
  68. package/dist/identity/oidc-userinfo.d.ts.map +1 -0
  69. package/dist/identity/oidc-userinfo.js +28 -0
  70. package/dist/identity/oidc-userinfo.js.map +1 -0
  71. package/dist/identity/oidc-verifier.d.ts +6 -0
  72. package/dist/identity/oidc-verifier.d.ts.map +1 -1
  73. package/dist/identity/oidc-verifier.js +35 -34
  74. package/dist/identity/oidc-verifier.js.map +1 -1
  75. package/dist/index.d.ts +7 -0
  76. package/dist/index.d.ts.map +1 -1
  77. package/dist/index.js +3 -0
  78. package/dist/index.js.map +1 -1
  79. package/dist/pipeline/interceptors/auth.d.ts +10 -0
  80. package/dist/pipeline/interceptors/auth.d.ts.map +1 -1
  81. package/dist/pipeline/interceptors/auth.js +9 -1
  82. package/dist/pipeline/interceptors/auth.js.map +1 -1
  83. package/dist/transport/console/handler.d.ts +22 -5
  84. package/dist/transport/console/handler.d.ts.map +1 -1
  85. package/dist/transport/console/handler.js +53 -24
  86. package/dist/transport/console/handler.js.map +1 -1
  87. package/package.json +4 -4
  88. package/src/boot/__tests__/auth-enabled-composition.test.ts +274 -4
  89. package/src/boot/__tests__/config.test.ts +41 -3
  90. package/src/boot/compose.ts +148 -15
  91. package/src/boot/config.ts +15 -0
  92. package/src/domain/apikey/__tests__/verifier.test.ts +146 -22
  93. package/src/domain/apikey/verifier.ts +24 -2
  94. package/src/domain/identityaccount/__tests__/constants.test.ts +155 -0
  95. package/src/domain/identityaccount/__tests__/identityaccount.test.ts +484 -0
  96. package/src/domain/identityaccount/__tests__/operator.test.ts +180 -0
  97. package/src/domain/identityaccount/__tests__/provisioning.test.ts +281 -0
  98. package/src/domain/identityaccount/__tests__/resolve.test.ts +114 -0
  99. package/src/domain/identityaccount/__tests__/resource-store.test.ts +216 -0
  100. package/src/domain/identityaccount/__tests__/support.ts +58 -0
  101. package/src/domain/identityaccount/constants.ts +178 -0
  102. package/src/domain/identityaccount/controller.ts +667 -0
  103. package/src/domain/identityaccount/operator.ts +94 -0
  104. package/src/domain/identityaccount/provisioning.ts +182 -0
  105. package/src/domain/identityaccount/resolve.ts +52 -0
  106. package/src/domain/identityaccount/resource-store.ts +157 -0
  107. package/src/domain/identityaccount/steps.ts +377 -0
  108. package/src/domain/identityaccount/store-contract.ts +471 -0
  109. package/src/domain/identityaccount/store.ts +70 -0
  110. package/src/extensions/__tests__/identity-account-composed.test.ts +803 -0
  111. package/src/extensions/__tests__/identity-account-points.test.ts +129 -0
  112. package/src/extensions/__tests__/registry.test.ts +4 -0
  113. package/src/extensions/__tests__/tier-truthfulness.test.ts +299 -0
  114. package/src/extensions/drivers.ts +26 -0
  115. package/src/extensions/gate-slots.ts +16 -0
  116. package/src/extensions/identity-federation.ts +58 -0
  117. package/src/extensions/registry.ts +44 -1
  118. package/src/identity/__tests__/oidc-discovery.test.ts +122 -0
  119. package/src/identity/__tests__/oidc-userinfo.test.ts +103 -0
  120. package/src/identity/__tests__/oidc-verifier.test.ts +173 -3
  121. package/src/identity/oidc-discovery.ts +84 -0
  122. package/src/identity/oidc-userinfo.ts +64 -0
  123. package/src/identity/oidc-verifier.ts +42 -45
  124. package/src/index.ts +19 -0
  125. package/src/pipeline/interceptors/auth.ts +13 -1
  126. package/src/pipeline/steps/__tests__/authorize-annotation-completeness.test.ts +184 -0
  127. package/src/transport/console/__tests__/handler.test.ts +93 -23
  128. package/src/transport/console/__tests__/resolver.test.ts +6 -0
  129. package/src/transport/console/handler.ts +84 -30
@@ -0,0 +1,377 @@
1
+ /**
2
+ * Identity-account domain steps (20260911.11; T01_1_review.md A1, A9).
3
+ *
4
+ * This is the first OSS domain whose persistence is a PORT
5
+ * (IdentityAccountStore) rather than the generic Store, so the cloud can
6
+ * serve the same controller over its own table. The shared steps that
7
+ * touch storage (CheckDuplicate, LoadExisting, LoadTarget, Persist, the
8
+ * delete pair) are typed over `Store`; this module brings the domain's
9
+ * own under the SAME names, so a chain reads onto the inventory unchanged
10
+ * (the organization domain's "own step, same name" precedent), and reuses
11
+ * every storage-free shared step as it is. The shared context keys are
12
+ * reused too, so BuildUpdateState finds EXISTING_RESOURCE_KEY exactly
13
+ * where it expects it.
14
+ *
15
+ * The domain's own rules:
16
+ * - DefaultAccountName: `metadata.name` falls back to the email, then
17
+ * the subject. ResolveSlug refuses an empty name and the field is not
18
+ * proto-required, and a machine subject whose IdP releases no email
19
+ * must still provision.
20
+ * - DeriveAccountId (A1): `metadata.id = accountIdFor(spec.idp_id)`,
21
+ * replacing whatever the caller sent (the organization's CopySlugToId
22
+ * precedent, `metadata.proto`'s documented exception). Runs BEFORE
23
+ * BuildNewState, which mints only when the id is empty.
24
+ * - CheckDuplicate: by SUBJECT, a primary-key read of the derived id —
25
+ * two accounts may share an email; the account's uniqueness is its
26
+ * subject, never its slug (the guideline's "domains with different
27
+ * uniqueness rules bring their own step under the same name").
28
+ * - AssignBackendFields: `is_machine_account` from the `@clients`
29
+ * suffix and `provisioning_mode direct` on create (the spec's
30
+ * "assigned by backend" fields); on update, every backend-assigned
31
+ * field and the email are preserved from the existing row (A9: the
32
+ * cloud's writable surface — first/last name, picture, preferences —
33
+ * and nothing else; the `preserveImmutableFields` shape).
34
+ * - GuardImmutableSubject (A1): a changed `spec.idp_id` is refused
35
+ * FAILED_PRECONDITION (the schedule domain's shape), never silently
36
+ * preserved — the subject IS the identity, and a client that sends a
37
+ * different one has a bug it should hear about.
38
+ *
39
+ * NOT_FOUND copy is the domain's (`Identity account not found: <id>`, the
40
+ * cloud's byte-pinned sentence); the shared `notFoundError` would render
41
+ * the kind_meta name `IdentityAccount`.
42
+ */
43
+ import { Code, ConnectError } from "@connectrpc/connect";
44
+ import type { DescMessage } from "@bufbuild/protobuf";
45
+ import { create } from "@bufbuild/protobuf";
46
+
47
+ import type { IdentityAccount } from "@stigmer/protos/ai/stigmer/iam/identityaccount/v1/api_pb";
48
+ import { IdentityAccountSchema } from "@stigmer/protos/ai/stigmer/iam/identityaccount/v1/api_pb";
49
+ import { IdentityAccountProvisioningMode } from "@stigmer/protos/ai/stigmer/iam/identityaccount/v1/enum_pb";
50
+ import type { IdentityAccountSpec } from "@stigmer/protos/ai/stigmer/iam/identityaccount/v1/spec_pb";
51
+ import { IdentityAccountSpecSchema } from "@stigmer/protos/ai/stigmer/iam/identityaccount/v1/spec_pb";
52
+
53
+ import {
54
+ alreadyExistsError,
55
+ failedPreconditionError,
56
+ internalError,
57
+ invalidArgumentError,
58
+ } from "../../pipeline/errors.js";
59
+ import type { PipelineStep } from "../../pipeline/pipeline.js";
60
+ import type { RequestContext } from "../../pipeline/request-context.js";
61
+ import { RESOURCE_ID_KEY } from "../../pipeline/steps/delete.js";
62
+ import { EXISTING_RESOURCE_KEY } from "../../pipeline/steps/load-existing.js";
63
+ import { TARGET_RESOURCE_KEY } from "../../pipeline/steps/load-target.js";
64
+ import { idValueOf, metadataOf } from "../../pipeline/steps/shapes.js";
65
+ import {
66
+ accountIdFor,
67
+ accountNotFoundMessage,
68
+ idpIdImmutableMessage,
69
+ isMachineSubject,
70
+ } from "./constants.js";
71
+ import { DuplicateAccountError } from "./store.js";
72
+ import type { IdentityAccountStore } from "./store.js";
73
+
74
+ type AccountStep = PipelineStep<typeof IdentityAccountSchema>;
75
+ type AccountContext = RequestContext<typeof IdentityAccountSchema>;
76
+
77
+ /** The kind's name as the shared error constructors render it. */
78
+ const KIND_NAME = "IdentityAccount";
79
+
80
+ /** `Identity account not found: <handle>` as a NOT_FOUND ConnectError. */
81
+ export function accountNotFoundError(handle: string): ConnectError {
82
+ return new ConnectError(accountNotFoundMessage(handle), Code.NotFound);
83
+ }
84
+
85
+ // ---------------------------------------------------------------------------
86
+ // Create
87
+ // ---------------------------------------------------------------------------
88
+
89
+ export function newDefaultAccountNameStep(): AccountStep {
90
+ return {
91
+ name: "DefaultAccountName",
92
+ execute(ctx: AccountContext): void {
93
+ const metadata = requireMetadata(ctx, "default account name");
94
+ if (metadata.name === "") {
95
+ const spec = specOf(ctx);
96
+ metadata.name = spec.email !== "" ? spec.email : spec.idpId;
97
+ }
98
+ },
99
+ };
100
+ }
101
+
102
+ export function newDeriveAccountIdStep(): AccountStep {
103
+ return {
104
+ name: "DeriveAccountId",
105
+ execute(ctx: AccountContext): void {
106
+ const metadata = requireMetadata(ctx, "derive account id");
107
+ // ValidateProto ran upstream (idp_id is required), so an empty
108
+ // subject here is a pipeline-ordering bug, not bad client input.
109
+ const idpId = specOf(ctx).idpId;
110
+ if (idpId === "") {
111
+ throw internalError(
112
+ new Error("identity account idp_id is empty"),
113
+ "derive account id",
114
+ );
115
+ }
116
+ metadata.id = accountIdFor(idpId);
117
+ },
118
+ };
119
+ }
120
+
121
+ /** Rejects a create whose SUBJECT already has an account (a primary-key read). */
122
+ export function newCheckDuplicateStep(
123
+ accounts: IdentityAccountStore,
124
+ ): AccountStep {
125
+ return {
126
+ name: "CheckDuplicate",
127
+ async execute(ctx: AccountContext): Promise<void> {
128
+ const metadata = requireMetadata(ctx, "duplicate check");
129
+ const idpId = specOf(ctx).idpId;
130
+ let existing: IdentityAccount | undefined;
131
+ try {
132
+ existing = await accounts.findById(metadata.id);
133
+ } catch (error) {
134
+ throw internalError(
135
+ error,
136
+ "failed to check for duplicate identity account",
137
+ );
138
+ }
139
+ if (existing !== undefined) {
140
+ throw alreadyExistsError(KIND_NAME, `subject '${idpId}'`);
141
+ }
142
+ },
143
+ };
144
+ }
145
+
146
+ /**
147
+ * The backend-assigned fields of a DIRECT account, applied in place: the
148
+ * machine flag from the subject's shape, `provisioning_mode direct`, no
149
+ * provider ref (a direct account is the platform's own subject). The ONE
150
+ * writer of these fields; the provisioner builds its spec through it too.
151
+ */
152
+ export function assignDirectBackendFields(
153
+ spec: IdentityAccountSpec,
154
+ ): IdentityAccountSpec {
155
+ spec.isMachineAccount = isMachineSubject(spec.idpId);
156
+ spec.provisioningMode = IdentityAccountProvisioningMode.direct;
157
+ spec.identityProviderRef = undefined;
158
+ return spec;
159
+ }
160
+
161
+ export function newAssignBackendFieldsStep(): AccountStep {
162
+ return {
163
+ name: "AssignBackendFields",
164
+ execute(ctx: AccountContext): void {
165
+ assignDirectBackendFields(specOf(ctx));
166
+ },
167
+ };
168
+ }
169
+
170
+ /** Saves the new account through the port; a held subject is ALREADY_EXISTS. */
171
+ export function newPersistNewAccountStep(
172
+ accounts: IdentityAccountStore,
173
+ ): AccountStep {
174
+ return {
175
+ name: "Persist",
176
+ async execute(ctx: AccountContext): Promise<void> {
177
+ try {
178
+ await accounts.save(ctx.newState);
179
+ } catch (error) {
180
+ if (error instanceof DuplicateAccountError) {
181
+ // The race the duplicate check could not see: another writer
182
+ // took the subject between the read and this save. The
183
+ // provisioner's race arm resolves the winner from this code.
184
+ throw alreadyExistsError(KIND_NAME, `subject '${specOf(ctx).idpId}'`);
185
+ }
186
+ throw internalError(error, "failed to save identity account");
187
+ }
188
+ },
189
+ };
190
+ }
191
+
192
+ // ---------------------------------------------------------------------------
193
+ // Update
194
+ // ---------------------------------------------------------------------------
195
+
196
+ /** Loads the existing account by id into EXISTING_RESOURCE_KEY; the domain's NOT_FOUND copy. */
197
+ export function newLoadExistingAccountStep(
198
+ accounts: IdentityAccountStore,
199
+ ): AccountStep {
200
+ return {
201
+ name: "LoadExisting",
202
+ async execute(ctx: AccountContext): Promise<void> {
203
+ const metadata = requireMetadata(ctx, "load existing");
204
+ if (metadata.id === "") {
205
+ // By id only: an account's slug is derived from its display name
206
+ // and two accounts may share one, so a slug lookup could never be
207
+ // an identity.
208
+ throw invalidArgumentError("resource id is required for update");
209
+ }
210
+ const existing = await readAccount(accounts, metadata.id);
211
+ ctx.set(EXISTING_RESOURCE_KEY, existing);
212
+ },
213
+ };
214
+ }
215
+
216
+ export function newGuardImmutableSubjectStep(): AccountStep {
217
+ return {
218
+ name: "GuardImmutableSubject",
219
+ execute(ctx: AccountContext): void {
220
+ const existing = existingAccountOf(ctx);
221
+ const existingIdpId = existing.spec?.idpId ?? "";
222
+ if (specOf(ctx).idpId !== existingIdpId) {
223
+ throw failedPreconditionError(idpIdImmutableMessage(existingIdpId));
224
+ }
225
+ },
226
+ };
227
+ }
228
+
229
+ /** Preserves the backend-assigned fields and the email from the existing row (A9). */
230
+ export function newPreserveBackendFieldsStep(): AccountStep {
231
+ return {
232
+ name: "AssignBackendFields",
233
+ execute(ctx: AccountContext): void {
234
+ const existingSpec = existingAccountOf(ctx).spec;
235
+ const spec = specOf(ctx);
236
+ spec.idpId = existingSpec?.idpId ?? spec.idpId;
237
+ spec.email = existingSpec?.email ?? "";
238
+ spec.isMachineAccount = existingSpec?.isMachineAccount ?? false;
239
+ spec.provisioningMode =
240
+ existingSpec?.provisioningMode ??
241
+ IdentityAccountProvisioningMode.identity_account_provisioning_mode_unspecified;
242
+ spec.identityProviderRef = existingSpec?.identityProviderRef;
243
+ },
244
+ };
245
+ }
246
+
247
+ /** Replaces the row through the port. */
248
+ export function newPersistUpdatedAccountStep(
249
+ accounts: IdentityAccountStore,
250
+ ): AccountStep {
251
+ return {
252
+ name: "Persist",
253
+ async execute(ctx: AccountContext): Promise<void> {
254
+ try {
255
+ await accounts.update(ctx.newState);
256
+ } catch (error) {
257
+ throw internalError(error, "failed to save identity account");
258
+ }
259
+ },
260
+ };
261
+ }
262
+
263
+ // ---------------------------------------------------------------------------
264
+ // Get / Delete (ID-wrapper inputs)
265
+ // ---------------------------------------------------------------------------
266
+
267
+ /** Loads the account named by an ID-wrapper input into TARGET_RESOURCE_KEY. */
268
+ export function newLoadTargetAccountStep<InputDesc extends DescMessage>(
269
+ accounts: IdentityAccountStore,
270
+ ): PipelineStep<InputDesc> {
271
+ return {
272
+ name: "LoadTarget",
273
+ async execute(ctx: RequestContext<InputDesc>): Promise<void> {
274
+ const id = idValueOf(ctx.input);
275
+ if (id === "") {
276
+ throw invalidArgumentError("resource id is required");
277
+ }
278
+ ctx.set(TARGET_RESOURCE_KEY, await readAccount(accounts, id));
279
+ },
280
+ };
281
+ }
282
+
283
+ /** Loads the doomed account (after ExtractResourceId) so delete can return it. */
284
+ export function newLoadExistingAccountForDeleteStep<
285
+ InputDesc extends DescMessage,
286
+ >(accounts: IdentityAccountStore): PipelineStep<InputDesc> {
287
+ return {
288
+ name: "LoadExistingForDelete",
289
+ async execute(ctx: RequestContext<InputDesc>): Promise<void> {
290
+ ctx.set(
291
+ EXISTING_RESOURCE_KEY,
292
+ await readAccount(accounts, requireResourceId(ctx)),
293
+ );
294
+ },
295
+ };
296
+ }
297
+
298
+ export function newDeleteAccountStep<InputDesc extends DescMessage>(
299
+ accounts: IdentityAccountStore,
300
+ ): PipelineStep<InputDesc> {
301
+ return {
302
+ name: "DeleteResource",
303
+ async execute(ctx: RequestContext<InputDesc>): Promise<void> {
304
+ try {
305
+ await accounts.deleteById(requireResourceId(ctx));
306
+ } catch (error) {
307
+ throw internalError(error, "failed to delete identity account");
308
+ }
309
+ },
310
+ };
311
+ }
312
+
313
+ // ---------------------------------------------------------------------------
314
+ // Shared readers
315
+ // ---------------------------------------------------------------------------
316
+
317
+ /** The account by id, or the domain's NOT_FOUND; any other fault propagates. */
318
+ async function readAccount(
319
+ accounts: IdentityAccountStore,
320
+ id: string,
321
+ ): Promise<IdentityAccount> {
322
+ const account = await accounts.findById(id);
323
+ if (account === undefined) {
324
+ throw accountNotFoundError(id);
325
+ }
326
+ return account;
327
+ }
328
+
329
+ function requireMetadata(ctx: AccountContext, operation: string) {
330
+ const metadata = metadataOf(ctx.newState);
331
+ if (metadata === undefined) {
332
+ throw internalError(
333
+ new Error("identity account metadata is nil"),
334
+ operation,
335
+ );
336
+ }
337
+ return metadata;
338
+ }
339
+
340
+ /** The spec being built, created in place when the caller omitted it. */
341
+ function specOf(ctx: AccountContext): IdentityAccountSpec {
342
+ const state = ctx.newState;
343
+ if (state.spec === undefined) {
344
+ state.spec = create(IdentityAccountSpecSchema);
345
+ }
346
+ return state.spec;
347
+ }
348
+
349
+ function existingAccountOf(ctx: AccountContext): IdentityAccount {
350
+ const existing = ctx.get(EXISTING_RESOURCE_KEY) as
351
+ | IdentityAccount
352
+ | undefined;
353
+ if (existing === undefined) {
354
+ throw internalError(
355
+ new Error(
356
+ "existing identity account not found in context - LoadExisting must run first",
357
+ ),
358
+ "identity account update ordering",
359
+ );
360
+ }
361
+ return existing;
362
+ }
363
+
364
+ function requireResourceId<Desc extends DescMessage>(
365
+ ctx: RequestContext<Desc>,
366
+ ): string {
367
+ const id = ctx.get(RESOURCE_ID_KEY);
368
+ if (typeof id !== "string" || id === "") {
369
+ throw internalError(
370
+ new Error(
371
+ "resource id not found in context (ExtractResourceId must run first)",
372
+ ),
373
+ "identity account delete ordering",
374
+ );
375
+ }
376
+ return id;
377
+ }