@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
package/src/boot/compose.ts
CHANGED
|
@@ -28,6 +28,7 @@ import path from "node:path";
|
|
|
28
28
|
|
|
29
29
|
import type { ConnectRouter, Transport } from "@connectrpc/connect";
|
|
30
30
|
|
|
31
|
+
import { ApiResourceKind } from "@stigmer/protos/ai/stigmer/commons/apiresource/apiresourcekind/api_resource_kind_pb";
|
|
31
32
|
import { HealthCheckResponse_ServingStatus as ServingStatus } from "@stigmer/protos/grpc/health/v1/health_pb";
|
|
32
33
|
|
|
33
34
|
import { registerAgentServices } from "../domain/agent/controller.js";
|
|
@@ -64,6 +65,15 @@ import { registerChannelConversationServices } from "../domain/agentchannel/conv
|
|
|
64
65
|
import { registerChannelMessageServices } from "../domain/agentchannel/message.js";
|
|
65
66
|
import { registerAgentShareServices } from "../domain/agentshare/controller.js";
|
|
66
67
|
import { registerApiKeyServices } from "../domain/apikey/controller.js";
|
|
68
|
+
import {
|
|
69
|
+
newCreateAccountPath,
|
|
70
|
+
registerIdentityAccountServices,
|
|
71
|
+
} from "../domain/identityaccount/controller.js";
|
|
72
|
+
import { ensureOperatorAccount } from "../domain/identityaccount/operator.js";
|
|
73
|
+
import { newDirectAccountProvisioner } from "../domain/identityaccount/provisioning.js";
|
|
74
|
+
import { newResourceIdentityAccountStore } from "../domain/identityaccount/resource-store.js";
|
|
75
|
+
import { newIssuerDiscovery } from "../identity/oidc-discovery.js";
|
|
76
|
+
import { newOidcUserInfoClient } from "../identity/oidc-userinfo.js";
|
|
67
77
|
import { newApiKeyIdentityVerifier } from "../domain/apikey/verifier.js";
|
|
68
78
|
import type { IdentityVerifier } from "../extensions/identity.js";
|
|
69
79
|
import { registerArtifactServices } from "../domain/artifact/controller.js";
|
|
@@ -117,6 +127,7 @@ import {
|
|
|
117
127
|
} from "../observability/rpc-metrics.js";
|
|
118
128
|
import { buildInterceptorChain } from "../pipeline/chain.js";
|
|
119
129
|
import { createVerifierChainInterceptor } from "../pipeline/interceptors/auth.js";
|
|
130
|
+
import { operatorIdentitySnapshot } from "../pipeline/steps/defaults.js";
|
|
120
131
|
import { createErrorBoundaryInterceptor } from "../pipeline/interceptors/error-boundary.js";
|
|
121
132
|
import { createRequestMetricsInterceptor } from "../pipeline/interceptors/request-metrics.js";
|
|
122
133
|
import { newPermissiveSingleTeamAuthorizer } from "../pipeline/steps/authorize.js";
|
|
@@ -221,6 +232,13 @@ export interface ComposedServer {
|
|
|
221
232
|
* them or reorder them, and both are the drift DD-007 rules out.
|
|
222
233
|
*/
|
|
223
234
|
identityVerifiers: ReadonlyArray<IdentityVerifier>;
|
|
235
|
+
/**
|
|
236
|
+
* The one routes function both routers are built from (exposed for
|
|
237
|
+
* tests, the `store` posture — the tier-truthfulness invariant
|
|
238
|
+
* enumerates the services a composition registers through it; the
|
|
239
|
+
* server registers no reflection service, deliberately).
|
|
240
|
+
*/
|
|
241
|
+
routes: (router: ConnectRouter) => void;
|
|
224
242
|
/** Completes wiring, flips SERVING, binds the port; returns the bound port. */
|
|
225
243
|
start(): Promise<number>;
|
|
226
244
|
/** NOT_SERVING first, stop background work, drain connections. */
|
|
@@ -294,6 +312,75 @@ export async function composeServer(
|
|
|
294
312
|
});
|
|
295
313
|
}
|
|
296
314
|
|
|
315
|
+
// The require-authentication posture has two sources OR'd here: the
|
|
316
|
+
// OSS OIDC issuer (O3 rulings Q1+Q2) and a composed unit's declaration
|
|
317
|
+
// (registry point, entry 20260904.02). Neither source = the OSS
|
|
318
|
+
// trusted-local posture, byte-identical wire behavior. Resolved in the
|
|
319
|
+
// storage stage because the posture decides a WRITE below (the operator
|
|
320
|
+
// account); the verifier chain it also governs is built at the end.
|
|
321
|
+
const authEnabled = config.oidcIssuer !== "";
|
|
322
|
+
const requireAuthentication =
|
|
323
|
+
authEnabled || extensions.requireAuthentication !== undefined;
|
|
324
|
+
|
|
325
|
+
// Stage: identity accounts (20260911.11) — the first domain whose
|
|
326
|
+
// persistence is a PORT rather than the generic Store. The composed
|
|
327
|
+
// driver (`drivers.identityAccountStore`, Q-IA-9) serves when a unit
|
|
328
|
+
// registers one; otherwise open source's adapter over `store` installs
|
|
329
|
+
// HERE (the default lives with the consumer, never in the registry).
|
|
330
|
+
// This ONE binding is what the create path, the provisioner, the
|
|
331
|
+
// operator ensure and both OSS verifiers read, so a composed driver is
|
|
332
|
+
// followed everywhere by construction. The domain's one create path is
|
|
333
|
+
// built once and shared by the create RPC, provisioning and the operator
|
|
334
|
+
// ensure below.
|
|
335
|
+
const identityAccounts =
|
|
336
|
+
extensions.drivers.identityAccountStore ??
|
|
337
|
+
newResourceIdentityAccountStore(store);
|
|
338
|
+
const identityAccountPath = {
|
|
339
|
+
accounts: identityAccounts,
|
|
340
|
+
logger,
|
|
341
|
+
authorizer,
|
|
342
|
+
authorizationLifecycle,
|
|
343
|
+
};
|
|
344
|
+
const createIdentityAccount = newCreateAccountPath(
|
|
345
|
+
identityAccountPath,
|
|
346
|
+
ApiResourceKind.identity_account,
|
|
347
|
+
);
|
|
348
|
+
// ONE issuer discovery for every lane that reads a well-known document
|
|
349
|
+
// (A8): provisioning's userinfo client here and the OIDC verifier's
|
|
350
|
+
// JWKS location below share it, so an issuer is discovered once per
|
|
351
|
+
// process and validated the same way wherever it is consumed. Built in
|
|
352
|
+
// this stage, not inside routes() (which runs twice) — the same reason
|
|
353
|
+
// the searchRegistry lives here.
|
|
354
|
+
const issuerDiscovery = newIssuerDiscovery();
|
|
355
|
+
// Provisioning reads the caller's profile from the userinfo endpoint of
|
|
356
|
+
// the issuer that vouched for the token, discovered per issuer and
|
|
357
|
+
// cached (A8) — so a composition whose own verifiers name their issuer
|
|
358
|
+
// on the identity needs no knob here.
|
|
359
|
+
const identityAccountProvisioner = newDirectAccountProvisioner({
|
|
360
|
+
accounts: identityAccounts,
|
|
361
|
+
createAccount: createIdentityAccount,
|
|
362
|
+
userInfo: newOidcUserInfoClient(issuerDiscovery),
|
|
363
|
+
});
|
|
364
|
+
// Under the trusted-local posture the server knows its one principal
|
|
365
|
+
// from the operator seam (the SAME seam the interceptor stamps callers
|
|
366
|
+
// from — never config directly, so the row and the stamp cannot
|
|
367
|
+
// disagree; T01_1_review.md A2, A5) and states the fact at boot:
|
|
368
|
+
// create-if-absent, so preferences set later survive every reboot. A
|
|
369
|
+
// composition with an authentication posture never creates one — its
|
|
370
|
+
// users provision themselves on first sign-in. A stage that writes a
|
|
371
|
+
// resource is new here; it earns its place because it is the truth of
|
|
372
|
+
// that posture, and a failure is a loud boot throw like the storage
|
|
373
|
+
// stage's own.
|
|
374
|
+
if (!requireAuthentication) {
|
|
375
|
+
const operator = await ensureOperatorAccount(
|
|
376
|
+
{ accounts: identityAccounts, createAccount: createIdentityAccount },
|
|
377
|
+
operatorIdentitySnapshot(),
|
|
378
|
+
);
|
|
379
|
+
logger.info("trusted-local operator account ensured", {
|
|
380
|
+
accountId: operator.metadata?.id ?? "",
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
|
|
297
384
|
// Stage: keys — the ratified fail-loud boot ASYMMETRY (D2 cross-domain
|
|
298
385
|
// invariants; Go server.go:277-293):
|
|
299
386
|
//
|
|
@@ -799,6 +886,17 @@ export async function composeServer(
|
|
|
799
886
|
authorizationLifecycle,
|
|
800
887
|
listReadScope: extensions.drivers.listReadScope,
|
|
801
888
|
});
|
|
889
|
+
// IdentityAccount (20260911.11): served once by open source in every
|
|
890
|
+
// edition over the store PORT bound in the identity-accounts stage; a
|
|
891
|
+
// composition adds what differs per edition through the registry —
|
|
892
|
+
// the federation capability (the four federated RPC arms) and the
|
|
893
|
+
// provision slot's gate steps (the cloud's personal organization).
|
|
894
|
+
registerIdentityAccountServices(router, {
|
|
895
|
+
...identityAccountPath,
|
|
896
|
+
provisioner: identityAccountProvisioner,
|
|
897
|
+
federation: extensions.drivers.identityFederation,
|
|
898
|
+
gateSteps: extensions.gateSteps,
|
|
899
|
+
});
|
|
802
900
|
registerEnvironmentServices(router, {
|
|
803
901
|
store,
|
|
804
902
|
logger,
|
|
@@ -1092,14 +1190,8 @@ export async function composeServer(
|
|
|
1092
1190
|
const inProcessWiring = createInProcessClients(routes, logger);
|
|
1093
1191
|
inProcess = inProcessWiring.clients;
|
|
1094
1192
|
|
|
1095
|
-
//
|
|
1096
|
-
//
|
|
1097
|
-
// (registry point, entry 20260904.02). Neither source = the OSS
|
|
1098
|
-
// trusted-local posture, byte-identical wire behavior.
|
|
1099
|
-
const authEnabled = config.oidcIssuer !== "";
|
|
1100
|
-
const requireAuthentication =
|
|
1101
|
-
authEnabled || extensions.requireAuthentication !== undefined;
|
|
1102
|
-
// What the posture registers, in Java's ProviderManager order (opaque
|
|
1193
|
+
// What the posture (resolved in the storage stage) registers, in Java's
|
|
1194
|
+
// ProviderManager order (opaque
|
|
1103
1195
|
// keys first — the cheapest claim — then the JWT lanes), ahead of the
|
|
1104
1196
|
// extension entries ("OSS entries first", the registry contract):
|
|
1105
1197
|
// - the API-key verifier rides the POSTURE, not the issuer
|
|
@@ -1108,19 +1200,31 @@ export async function composeServer(
|
|
|
1108
1200
|
// authentication posture must honor the keys it issues. Gating it on
|
|
1109
1201
|
// the issuer left a posture-declaring composition minting keys
|
|
1110
1202
|
// nothing verified.
|
|
1111
|
-
// - the OIDC verifier rides the ISSUER alone: it
|
|
1112
|
-
//
|
|
1113
|
-
//
|
|
1114
|
-
//
|
|
1203
|
+
// - the OIDC verifier rides the ISSUER alone: it is open source's
|
|
1204
|
+
// single-issuer lane, and a composition with verifiers of its own
|
|
1205
|
+
// names their issuers and audiences itself and declares the posture
|
|
1206
|
+
// instead — so the knob and the extension entries never both claim
|
|
1207
|
+
// one JWT.
|
|
1208
|
+
// - both OSS verifiers resolve their subject through the identity-
|
|
1209
|
+
// account domain (20260911.11 Q-IA-2, A6): a provisioned user is
|
|
1210
|
+
// stamped with the account id whichever credential they present,
|
|
1211
|
+
// an unprovisioned one is admitted idp-shaped. They read the SAME
|
|
1212
|
+
// port the domain writes through (`identityAccounts`, the
|
|
1213
|
+
// identity-accounts stage — a composed driver included), so a row
|
|
1214
|
+
// provisioning creates resolves on the very next request.
|
|
1115
1215
|
// The runner's credential in either posture is an operator-minted API
|
|
1116
1216
|
// token via STIGMER_TOKEN (O3 ruling Q3) — no runner-specific verifier.
|
|
1117
1217
|
const identityVerifiers = [
|
|
1118
|
-
...(requireAuthentication
|
|
1218
|
+
...(requireAuthentication
|
|
1219
|
+
? [newApiKeyIdentityVerifier({ store, accounts: identityAccounts })]
|
|
1220
|
+
: []),
|
|
1119
1221
|
...(authEnabled
|
|
1120
1222
|
? [
|
|
1121
1223
|
newOidcIdentityVerifier({
|
|
1122
1224
|
issuer: config.oidcIssuer,
|
|
1123
1225
|
audience: config.oidcAudience,
|
|
1226
|
+
accounts: identityAccounts,
|
|
1227
|
+
discovery: issuerDiscovery,
|
|
1124
1228
|
}),
|
|
1125
1229
|
]
|
|
1126
1230
|
: []),
|
|
@@ -1203,6 +1307,7 @@ export async function composeServer(
|
|
|
1203
1307
|
workflowExecutionStreamBroker,
|
|
1204
1308
|
inProcessTransport: inProcessWiring.transport,
|
|
1205
1309
|
identityVerifiers,
|
|
1310
|
+
routes,
|
|
1206
1311
|
|
|
1207
1312
|
async start(): Promise<number> {
|
|
1208
1313
|
// Temporal boot is NON-fatal end to end (Go server.go): a failed
|
|
@@ -6,6 +6,15 @@
|
|
|
6
6
|
* authenticates-as-owner identity mapping (created_by actor → identityId
|
|
7
7
|
* + display fields), and instant revocation (delete visible on the very
|
|
8
8
|
* next verify — the no-cache posture).
|
|
9
|
+
*
|
|
10
|
+
* Since 20260911.11 (T01_1_review.md A6) the creator stamp is resolved
|
|
11
|
+
* the way the OIDC lane resolves `sub`: a key minted before its owner
|
|
12
|
+
* was provisioned carries the raw subject and answers the owner's
|
|
13
|
+
* ACCOUNT id once one exists; a stamp that is already an account id is
|
|
14
|
+
* kept; one primary-key read either way, no cache. The account store is
|
|
15
|
+
* the REAL open-source adapter over the same SQLite store the keys live
|
|
16
|
+
* in, so what is pinned is the production pair; only the fault arm
|
|
17
|
+
* substitutes a one-method lookup that rejects.
|
|
9
18
|
*/
|
|
10
19
|
import { mkdtempSync, rmSync } from "node:fs";
|
|
11
20
|
import { tmpdir } from "node:os";
|
|
@@ -18,9 +27,15 @@ import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
|
18
27
|
|
|
19
28
|
import { ApiResourceKind } from "@stigmer/protos/ai/stigmer/commons/apiresource/apiresourcekind/api_resource_kind_pb";
|
|
20
29
|
import { ApiKeySchema } from "@stigmer/protos/ai/stigmer/iam/apikey/v1/api_pb";
|
|
30
|
+
import { IdentityAccountSchema } from "@stigmer/protos/ai/stigmer/iam/identityaccount/v1/api_pb";
|
|
31
|
+
import { IdentityAccountProvisioningMode } from "@stigmer/protos/ai/stigmer/iam/identityaccount/v1/enum_pb";
|
|
21
32
|
|
|
22
33
|
import { createLogger } from "../../../boot/logger.js";
|
|
23
34
|
import { SqliteStore } from "../../../store/sqlite/store.js";
|
|
35
|
+
import { accountIdFor } from "../../identityaccount/constants.js";
|
|
36
|
+
import type { AccountsBySubject } from "../../identityaccount/resolve.js";
|
|
37
|
+
import { newResourceIdentityAccountStore } from "../../identityaccount/resource-store.js";
|
|
38
|
+
import type { IdentityAccountStore } from "../../identityaccount/store.js";
|
|
24
39
|
import { generateApiKeyPlaintext, hashApiKey } from "../keymaterial.js";
|
|
25
40
|
import {
|
|
26
41
|
INVALID_TOKEN_MESSAGE,
|
|
@@ -30,6 +45,7 @@ import {
|
|
|
30
45
|
|
|
31
46
|
let dir: string;
|
|
32
47
|
let store: SqliteStore;
|
|
48
|
+
let accounts: IdentityAccountStore;
|
|
33
49
|
|
|
34
50
|
beforeAll(() => {
|
|
35
51
|
dir = mkdtempSync(path.join(tmpdir(), "apikey-verifier-test-"));
|
|
@@ -37,8 +53,32 @@ beforeAll(() => {
|
|
|
37
53
|
path.join(dir, "stigmer.db"),
|
|
38
54
|
createLogger({ level: "error", pretty: false, write: () => {} }),
|
|
39
55
|
);
|
|
56
|
+
accounts = newResourceIdentityAccountStore(store);
|
|
40
57
|
});
|
|
41
58
|
|
|
59
|
+
/** The verifier as compose builds it: the key store and the account port over ONE store. */
|
|
60
|
+
function verifier(lookup: AccountsBySubject = accounts) {
|
|
61
|
+
return newApiKeyIdentityVerifier({ store, accounts: lookup });
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** A direct account for `sub` through the real adapter — derived id, as provisioning writes it. */
|
|
65
|
+
async function provisionAccount(sub: string, email: string): Promise<string> {
|
|
66
|
+
const id = accountIdFor(sub);
|
|
67
|
+
await accounts.save(
|
|
68
|
+
create(IdentityAccountSchema, {
|
|
69
|
+
apiVersion: "iam.stigmer.ai/v1",
|
|
70
|
+
kind: "IdentityAccount",
|
|
71
|
+
metadata: { id, name: email },
|
|
72
|
+
spec: {
|
|
73
|
+
idpId: sub,
|
|
74
|
+
email,
|
|
75
|
+
provisioningMode: IdentityAccountProvisioningMode.direct,
|
|
76
|
+
},
|
|
77
|
+
}),
|
|
78
|
+
);
|
|
79
|
+
return id;
|
|
80
|
+
}
|
|
81
|
+
|
|
42
82
|
afterAll(async () => {
|
|
43
83
|
await store.close();
|
|
44
84
|
rmSync(dir, { recursive: true, force: true });
|
|
@@ -75,7 +115,10 @@ async function seedKey(options?: {
|
|
|
75
115
|
: {}),
|
|
76
116
|
},
|
|
77
117
|
status: {
|
|
78
|
-
audit: {
|
|
118
|
+
audit: {
|
|
119
|
+
specAudit: { createdBy: owner },
|
|
120
|
+
statusAudit: { createdBy: owner },
|
|
121
|
+
},
|
|
79
122
|
},
|
|
80
123
|
}),
|
|
81
124
|
);
|
|
@@ -84,16 +127,16 @@ async function seedKey(options?: {
|
|
|
84
127
|
|
|
85
128
|
describe("claim-or-pass", () => {
|
|
86
129
|
it("passes (null) on anything without the stk_ prefix", async () => {
|
|
87
|
-
const
|
|
88
|
-
expect(await
|
|
89
|
-
expect(await
|
|
130
|
+
const apikey = verifier();
|
|
131
|
+
expect(await apikey.verify("eyJhbGciOiJSUzI1NiJ9.x.y")).toBeNull();
|
|
132
|
+
expect(await apikey.verify("Basic-ish-token")).toBeNull();
|
|
90
133
|
});
|
|
91
134
|
|
|
92
135
|
it("claims stk_ tokens case-insensitively (Java startsWithIgnoreCase)", async () => {
|
|
93
|
-
const
|
|
136
|
+
const apikey = verifier();
|
|
94
137
|
// Recognized but unknown — must THROW, never pass (a forged key must
|
|
95
138
|
// not fall through to a laxer verifier).
|
|
96
|
-
await expect(
|
|
139
|
+
await expect(apikey.verify("STK_unknown")).rejects.toSatisfy(
|
|
97
140
|
(error: unknown) => {
|
|
98
141
|
expect(ConnectError.from(error).code).toBe(Code.Unauthenticated);
|
|
99
142
|
expect(ConnectError.from(error).rawMessage).toBe(INVALID_TOKEN_MESSAGE);
|
|
@@ -110,7 +153,7 @@ describe("verification arms", () => {
|
|
|
110
153
|
email: "owner@example.com",
|
|
111
154
|
displayName: "Key Owner",
|
|
112
155
|
});
|
|
113
|
-
const identity = await
|
|
156
|
+
const identity = await verifier().verify(plaintext);
|
|
114
157
|
expect(identity).toEqual({
|
|
115
158
|
identityId: "ida_owner1",
|
|
116
159
|
callerClass: "user",
|
|
@@ -125,29 +168,29 @@ describe("verification arms", () => {
|
|
|
125
168
|
const { plaintext } = await seedKey({
|
|
126
169
|
expiresAt: new Date(Date.now() - 60_000),
|
|
127
170
|
});
|
|
128
|
-
await expect(
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
171
|
+
await expect(verifier().verify(plaintext)).rejects.toSatisfy(
|
|
172
|
+
(error: unknown) => {
|
|
173
|
+
expect(ConnectError.from(error).rawMessage).toBe(TOKEN_EXPIRED_MESSAGE);
|
|
174
|
+
return true;
|
|
175
|
+
},
|
|
176
|
+
);
|
|
134
177
|
});
|
|
135
178
|
|
|
136
179
|
it("a future expiry verifies fine (expires_at set and NOT past)", async () => {
|
|
137
180
|
const { plaintext } = await seedKey({
|
|
138
181
|
expiresAt: new Date(Date.now() + 60_000),
|
|
139
182
|
});
|
|
140
|
-
const identity = await
|
|
183
|
+
const identity = await verifier().verify(plaintext);
|
|
141
184
|
expect(identity?.callerClass).toBe("user");
|
|
142
185
|
});
|
|
143
186
|
|
|
144
187
|
it("a deleted key is rejected on the very next verify (no cache)", async () => {
|
|
145
188
|
const { plaintext, id } = await seedKey();
|
|
146
|
-
const
|
|
147
|
-
expect(await
|
|
189
|
+
const apikey = verifier();
|
|
190
|
+
expect(await apikey.verify(plaintext)).not.toBeNull();
|
|
148
191
|
|
|
149
192
|
await store.deleteResource(ApiResourceKind.api_key, id);
|
|
150
|
-
await expect(
|
|
193
|
+
await expect(apikey.verify(plaintext)).rejects.toSatisfy(
|
|
151
194
|
(error: unknown) => {
|
|
152
195
|
expect(ConnectError.from(error).rawMessage).toBe(INVALID_TOKEN_MESSAGE);
|
|
153
196
|
return true;
|
|
@@ -170,11 +213,92 @@ describe("verification arms", () => {
|
|
|
170
213
|
spec: { keyHash: hashApiKey(plaintext) },
|
|
171
214
|
}),
|
|
172
215
|
);
|
|
173
|
-
await expect(
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
216
|
+
await expect(verifier().verify(plaintext)).rejects.toSatisfy(
|
|
217
|
+
(error: unknown) => {
|
|
218
|
+
expect(ConnectError.from(error).rawMessage).toBe(INVALID_TOKEN_MESSAGE);
|
|
219
|
+
return true;
|
|
220
|
+
},
|
|
221
|
+
);
|
|
222
|
+
});
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
describe("creator stamp → account resolution (20260911.11 A6)", () => {
|
|
226
|
+
it("a key minted BEFORE its owner was provisioned resolves to the owner's account once one exists; the display fields stay the stamp's", async () => {
|
|
227
|
+
const { plaintext } = await seedKey({
|
|
228
|
+
ownerId: "auth0|early-adopter",
|
|
229
|
+
email: "early@example.com",
|
|
230
|
+
displayName: "Early Adopter",
|
|
231
|
+
});
|
|
232
|
+
const accountId = await provisionAccount(
|
|
233
|
+
"auth0|early-adopter",
|
|
234
|
+
"early@example.com",
|
|
235
|
+
);
|
|
236
|
+
expect(await verifier().verify(plaintext)).toEqual({
|
|
237
|
+
identityId: accountId,
|
|
238
|
+
callerClass: "user",
|
|
239
|
+
issuer: "",
|
|
240
|
+
rawToken: plaintext,
|
|
241
|
+
email: "early@example.com",
|
|
242
|
+
displayName: "Early Adopter",
|
|
178
243
|
});
|
|
179
244
|
});
|
|
245
|
+
|
|
246
|
+
it("a stamp that is already an account id is kept — no account carries it as a subject", async () => {
|
|
247
|
+
const accountId = await provisionAccount(
|
|
248
|
+
"auth0|provisioned-first",
|
|
249
|
+
"first@example.com",
|
|
250
|
+
);
|
|
251
|
+
const { plaintext } = await seedKey({ ownerId: accountId });
|
|
252
|
+
expect((await verifier().verify(plaintext))?.identityId).toBe(accountId);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it("resolution is one read per verify with no cache — an account provisioned between two verifies is seen by the second", async () => {
|
|
256
|
+
const { plaintext } = await seedKey({ ownerId: "auth0|late-bloomer" });
|
|
257
|
+
const apikey = verifier();
|
|
258
|
+
expect((await apikey.verify(plaintext))?.identityId).toBe(
|
|
259
|
+
"auth0|late-bloomer",
|
|
260
|
+
);
|
|
261
|
+
const accountId = await provisionAccount(
|
|
262
|
+
"auth0|late-bloomer",
|
|
263
|
+
"late@example.com",
|
|
264
|
+
);
|
|
265
|
+
expect((await apikey.verify(plaintext))?.identityId).toBe(accountId);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it("an account-store fault is an infrastructure fault (plain error), never a credential rejection", async () => {
|
|
269
|
+
const { plaintext } = await seedKey({ ownerId: "auth0|anyone" });
|
|
270
|
+
const broken: AccountsBySubject = {
|
|
271
|
+
findDirectByIdpId: () => Promise.reject(new Error("store is on fire")),
|
|
272
|
+
};
|
|
273
|
+
const error = await verifier(broken)
|
|
274
|
+
.verify(plaintext)
|
|
275
|
+
.then(
|
|
276
|
+
() => {
|
|
277
|
+
throw new Error("expected rejection");
|
|
278
|
+
},
|
|
279
|
+
(e: unknown) => e,
|
|
280
|
+
);
|
|
281
|
+
expect(error).not.toBeInstanceOf(ConnectError);
|
|
282
|
+
expect(String(error)).toContain("store is on fire");
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it("a revoked or expired key never reaches the account store — credential checks come first", async () => {
|
|
286
|
+
const reads: string[] = [];
|
|
287
|
+
const counting: AccountsBySubject = {
|
|
288
|
+
findDirectByIdpId: async (idpId) => {
|
|
289
|
+
reads.push(idpId);
|
|
290
|
+
return undefined;
|
|
291
|
+
},
|
|
292
|
+
};
|
|
293
|
+
const { plaintext: expired } = await seedKey({
|
|
294
|
+
expiresAt: new Date(Date.now() - 60_000),
|
|
295
|
+
});
|
|
296
|
+
await expect(verifier(counting).verify(expired)).rejects.toBeInstanceOf(
|
|
297
|
+
ConnectError,
|
|
298
|
+
);
|
|
299
|
+
await expect(
|
|
300
|
+
verifier(counting).verify("stk_never_minted"),
|
|
301
|
+
).rejects.toBeInstanceOf(ConnectError);
|
|
302
|
+
expect(reads).toEqual([]);
|
|
303
|
+
});
|
|
180
304
|
});
|
|
@@ -23,6 +23,16 @@
|
|
|
23
23
|
* seam wants. Expiry is "expires_at set and past"; never_expires is
|
|
24
24
|
* deliberately not read — Java parity (never-expiring keys leave
|
|
25
25
|
* expires_at unset).
|
|
26
|
+
*
|
|
27
|
+
* The stamp is then resolved the way the OIDC lane resolves `sub`
|
|
28
|
+
* (20260911.11, T01_1_review.md A6; domain/identityaccount/resolve.ts):
|
|
29
|
+
* a key minted before its owner was provisioned carries the raw subject
|
|
30
|
+
* and answers the owner's ACCOUNT id once one exists, so no write over a
|
|
31
|
+
* legacy key mints a raw-subject stamp again; a stamp that is already an
|
|
32
|
+
* account id is kept — one primary-key read either way, no cache. Only
|
|
33
|
+
* identityId changes on a hit: email/displayName stay the stamp's, the
|
|
34
|
+
* actor the key recorded at minting. The credential checks run first, so
|
|
35
|
+
* a revoked or expired key never reaches the account store.
|
|
26
36
|
*/
|
|
27
37
|
import { Code, ConnectError } from "@connectrpc/connect";
|
|
28
38
|
import { timestampDate } from "@bufbuild/protobuf/wkt";
|
|
@@ -32,6 +42,8 @@ import type {
|
|
|
32
42
|
IdentityVerifier,
|
|
33
43
|
} from "../../extensions/identity.js";
|
|
34
44
|
import type { Store } from "../../store/interface.js";
|
|
45
|
+
import { identityIdForSubject } from "../identityaccount/resolve.js";
|
|
46
|
+
import type { AccountsBySubject } from "../identityaccount/resolve.js";
|
|
35
47
|
import { hashApiKey, isApiKeyToken } from "./keymaterial.js";
|
|
36
48
|
import { findApiKeyByHash } from "./lookup.js";
|
|
37
49
|
|
|
@@ -41,7 +53,17 @@ export const INVALID_TOKEN_MESSAGE = "invalid token";
|
|
|
41
53
|
/** Java classifyAuthError's "expired" arm. */
|
|
42
54
|
export const TOKEN_EXPIRED_MESSAGE = "token has expired";
|
|
43
55
|
|
|
44
|
-
export
|
|
56
|
+
export interface ApiKeyVerifierDeps {
|
|
57
|
+
/** Where the keys live — the generic Store, by hash (lookup.ts). */
|
|
58
|
+
readonly store: Store;
|
|
59
|
+
/** The identity-account domain's subject lookup the creator stamp resolves through (A6). */
|
|
60
|
+
readonly accounts: AccountsBySubject;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function newApiKeyIdentityVerifier(
|
|
64
|
+
deps: ApiKeyVerifierDeps,
|
|
65
|
+
): IdentityVerifier {
|
|
66
|
+
const { store, accounts } = deps;
|
|
45
67
|
return {
|
|
46
68
|
name: "apikey",
|
|
47
69
|
async verify(token: string): Promise<CallerIdentity | null> {
|
|
@@ -64,7 +86,7 @@ export function newApiKeyIdentityVerifier(store: Store): IdentityVerifier {
|
|
|
64
86
|
throw new ConnectError(INVALID_TOKEN_MESSAGE, Code.Unauthenticated);
|
|
65
87
|
}
|
|
66
88
|
return {
|
|
67
|
-
identityId: owner.id,
|
|
89
|
+
identityId: await identityIdForSubject(accounts, owner.id),
|
|
68
90
|
callerClass: "user",
|
|
69
91
|
issuer: "",
|
|
70
92
|
rawToken: token,
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pins the identity-account domain's constants (project 20260911.11,
|
|
3
|
+
* T01_1_review.md A1 and A2):
|
|
4
|
+
*
|
|
5
|
+
* - the account-id derivation — `ida_` + the top 130 bits of
|
|
6
|
+
* sha256(idp_id) as 26 lowercase Crockford-base32 characters, so a
|
|
7
|
+
* derived id is indistinguishable in shape from a minted `{prefix}_{ulid}`
|
|
8
|
+
* and the primary key is the one home of "one account per subject".
|
|
9
|
+
* The golden vectors are wire-adjacent constants: a change here
|
|
10
|
+
* re-addresses every direct account open source ever created;
|
|
11
|
+
* - `idpIdOf(caller)` — the ONE place a caller becomes an issuer subject:
|
|
12
|
+
* a token-bearing caller's JWT `sub`; `local|<identityId>` for a caller
|
|
13
|
+
* with no issuer and no token (the trusted-local posture);
|
|
14
|
+
* - the byte-pinned copy moved from the cloud's handlers as-is, plus the
|
|
15
|
+
* two new sentences (the federation refusal, the immutable subject).
|
|
16
|
+
*/
|
|
17
|
+
import { describe, expect, it } from "vitest";
|
|
18
|
+
|
|
19
|
+
import type { CallerIdentity } from "../../../extensions/identity.js";
|
|
20
|
+
import {
|
|
21
|
+
ACCOUNT_NOT_FOUND_FOR_CALLER_MESSAGE,
|
|
22
|
+
FEDERATION_UNIMPLEMENTED_REASON,
|
|
23
|
+
LOCAL_IDP_ID_PREFIX,
|
|
24
|
+
NO_IDP_ID_MESSAGE,
|
|
25
|
+
USERINFO_UNAVAILABLE_PREFIX,
|
|
26
|
+
accountIdFor,
|
|
27
|
+
accountNotFoundMessage,
|
|
28
|
+
federationUnimplementedMessage,
|
|
29
|
+
idpIdImmutableMessage,
|
|
30
|
+
idpIdOf,
|
|
31
|
+
localIdpIdFor,
|
|
32
|
+
} from "../constants.js";
|
|
33
|
+
|
|
34
|
+
const CROCKFORD_ID = /^ida_[0-9abcdefghjkmnpqrstvwxyz]{26}$/;
|
|
35
|
+
|
|
36
|
+
describe("accountIdFor — the derived account id", () => {
|
|
37
|
+
it("pins the golden vectors (a change re-addresses every OSS account)", () => {
|
|
38
|
+
expect(accountIdFor("auth0|user123")).toBe(
|
|
39
|
+
"ida_wtr3jcf281yfk9xx61kj59fsme",
|
|
40
|
+
);
|
|
41
|
+
expect(accountIdFor("local|system")).toBe("ida_fn0zdvkkkhhrb4wry43zba8gnn");
|
|
42
|
+
expect(accountIdFor("local|operator@example.com")).toBe(
|
|
43
|
+
"ida_0byc5k14t1e7b7kdxft7hwz1f7",
|
|
44
|
+
);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("has the shape of every minted id: prefix, underscore, 26 Crockford chars", () => {
|
|
48
|
+
for (const subject of [
|
|
49
|
+
"auth0|user123",
|
|
50
|
+
"google-oauth2|109876543210",
|
|
51
|
+
"x",
|
|
52
|
+
]) {
|
|
53
|
+
expect(accountIdFor(subject)).toMatch(CROCKFORD_ID);
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("is a pure function of the subject", () => {
|
|
58
|
+
expect(accountIdFor("auth0|user123")).toBe(accountIdFor("auth0|user123"));
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("separates subjects that differ by one character", () => {
|
|
62
|
+
expect(accountIdFor("auth0|user123")).not.toBe(
|
|
63
|
+
accountIdFor("auth0|user124"),
|
|
64
|
+
);
|
|
65
|
+
expect(accountIdFor("auth0|user123")).not.toBe(
|
|
66
|
+
accountIdFor("auth0|User123"),
|
|
67
|
+
);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("hashes the UTF-8 bytes — unicode and long subjects derive stably", () => {
|
|
71
|
+
expect(accountIdFor("ünïcödé|日本語")).toBe(
|
|
72
|
+
"ida_sk0tfhnzv2mshyh5cvpb2c84fd",
|
|
73
|
+
);
|
|
74
|
+
expect(accountIdFor("a".repeat(4096))).toMatch(CROCKFORD_ID);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("refuses an empty subject — no principal, no address", () => {
|
|
78
|
+
expect(() => accountIdFor("")).toThrow("idp_id must not be empty");
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe("idpIdOf — the one place a caller becomes a subject", () => {
|
|
83
|
+
const base = { callerClass: "user", rawToken: "" } as const;
|
|
84
|
+
|
|
85
|
+
it("a caller with no issuer and no token is the local operator: local|<identityId>", () => {
|
|
86
|
+
const operator: CallerIdentity = {
|
|
87
|
+
...base,
|
|
88
|
+
identityId: "operator@example.com",
|
|
89
|
+
issuer: "",
|
|
90
|
+
};
|
|
91
|
+
expect(idpIdOf(operator)).toBe("local|operator@example.com");
|
|
92
|
+
expect(localIdpIdFor("system")).toBe(`${LOCAL_IDP_ID_PREFIX}system`);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("a token-bearing caller is its JWT subject, whatever identityId was resolved to", async () => {
|
|
96
|
+
// A three-segment JWT whose payload carries sub — the verifier already
|
|
97
|
+
// proved the signature; this function only reads the claim.
|
|
98
|
+
const payload = Buffer.from(
|
|
99
|
+
JSON.stringify({ sub: "auth0|user123" }),
|
|
100
|
+
).toString("base64url");
|
|
101
|
+
const resolved: CallerIdentity = {
|
|
102
|
+
...base,
|
|
103
|
+
identityId: accountIdFor("auth0|user123"),
|
|
104
|
+
issuer: "https://issuer.test",
|
|
105
|
+
rawToken: `eyJhbGciOiJSUzI1NiJ9.${payload}.sig`,
|
|
106
|
+
};
|
|
107
|
+
expect(idpIdOf(resolved)).toBe("auth0|user123");
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("a token without a readable subject yields the empty string (the UNAUTHENTICATED arm's input)", () => {
|
|
111
|
+
const opaque: CallerIdentity = {
|
|
112
|
+
...base,
|
|
113
|
+
identityId: "ida_whatever",
|
|
114
|
+
issuer: "https://issuer.test",
|
|
115
|
+
rawToken: "stk_not_a_jwt",
|
|
116
|
+
};
|
|
117
|
+
expect(idpIdOf(opaque)).toBe("");
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
describe("byte-pinned copy", () => {
|
|
122
|
+
it("moves the cloud handlers' sentences as-is", () => {
|
|
123
|
+
expect(ACCOUNT_NOT_FOUND_FOR_CALLER_MESSAGE).toBe(
|
|
124
|
+
"Identity account not found for the authenticated user",
|
|
125
|
+
);
|
|
126
|
+
expect(NO_IDP_ID_MESSAGE).toBe(
|
|
127
|
+
"Cannot provision account: no IDP ID in authentication context",
|
|
128
|
+
);
|
|
129
|
+
expect(USERINFO_UNAVAILABLE_PREFIX).toBe(
|
|
130
|
+
"Failed to fetch user profile from identity provider: ",
|
|
131
|
+
);
|
|
132
|
+
expect(accountNotFoundMessage("ida_x")).toBe(
|
|
133
|
+
"Identity account not found: ida_x",
|
|
134
|
+
);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("the two new sentences name the edition and the subject", () => {
|
|
138
|
+
// The organization directory's absent-method shape ("<method> is not
|
|
139
|
+
// implemented", controller.ts refusesEnumeration) so clients that match
|
|
140
|
+
// on it keep working, followed by the reason a person can act on.
|
|
141
|
+
expect(FEDERATION_UNIMPLEMENTED_REASON).toBe(
|
|
142
|
+
"federated identity accounts are served by the Enterprise and Cloud editions",
|
|
143
|
+
);
|
|
144
|
+
expect(
|
|
145
|
+
federationUnimplementedMessage(
|
|
146
|
+
"ai.stigmer.iam.identityaccount.v1.IdentityAccountCommandController.createFederatedAccount",
|
|
147
|
+
),
|
|
148
|
+
).toBe(
|
|
149
|
+
"ai.stigmer.iam.identityaccount.v1.IdentityAccountCommandController.createFederatedAccount is not implemented: federated identity accounts are served by the Enterprise and Cloud editions",
|
|
150
|
+
);
|
|
151
|
+
expect(idpIdImmutableMessage("auth0|user123")).toBe(
|
|
152
|
+
"spec.idp_id is immutable (account subject is 'auth0|user123') — create a new account for a different subject",
|
|
153
|
+
);
|
|
154
|
+
});
|
|
155
|
+
});
|