diff --git a/libraries/source-read-sdk/library.codefly.yaml b/libraries/source-read-sdk/library.codefly.yaml index ce0c08889..161cdad77 100644 --- a/libraries/source-read-sdk/library.codefly.yaml +++ b/libraries/source-read-sdk/library.codefly.yaml @@ -12,7 +12,7 @@ sources: module: saas-starter service: accounts endpoint: authority - contract-digest: sha256:d6a4564228c0a8ee8957d48d0413c642500f03373d14a2ead4d64d0536bbcbe7 + contract-digest: sha256:606e52170c2e55688ce163eb1d54c347f0d19ac40915d7fb6252f2034b787c0e services: - ModuleCapabilitiesService generated-by: diff --git a/module/contracts/api/accounts/authority/contract.binpb b/module/contracts/api/accounts/authority/contract.binpb index 70a52aba8..257438d43 100644 Binary files a/module/contracts/api/accounts/authority/contract.binpb and b/module/contracts/api/accounts/authority/contract.binpb differ diff --git a/module/contracts/api/accounts/authority/proto/saas/accounts/v1/installations.proto b/module/contracts/api/accounts/authority/proto/saas/accounts/v1/installations.proto index 61af98331..44d793e14 100644 --- a/module/contracts/api/accounts/authority/proto/saas/accounts/v1/installations.proto +++ b/module/contracts/api/accounts/authority/proto/saas/accounts/v1/installations.proto @@ -133,8 +133,14 @@ message InstallSolutionRequest { string root_scope_label = 6 [(buf.validate.field).string.max_len = 512]; // The least-privilege role granted to the agent at the root node. This is // deliberately NOT the full ":catalog" role; widening is a separate, - // audited admin action. - string role_id = 7 [(buf.validate.field).string.uuid = true]; + // audited admin action. Empty when the delivered module declares its own + // installation role: the host selects a named one, and "none" grants the + // agent no standing role at all. A role given for "none" is refused, and an + // empty one is refused for a target that declares no installation role. + string role_id = 7 [ + (buf.validate.field).ignore = IGNORE_IF_ZERO_VALUE, + (buf.validate.field).string.uuid = true + ]; // The accountable human of record. Must be a current org admin. Empty defaults // to the calling admin. string owner_principal_id = 8 [ diff --git a/module/contracts/api/accounts/connect/contract.binpb b/module/contracts/api/accounts/connect/contract.binpb index 70a52aba8..257438d43 100644 Binary files a/module/contracts/api/accounts/connect/contract.binpb and b/module/contracts/api/accounts/connect/contract.binpb differ diff --git a/module/contracts/api/accounts/connect/proto/saas/accounts/v1/installations.proto b/module/contracts/api/accounts/connect/proto/saas/accounts/v1/installations.proto index 61af98331..44d793e14 100644 --- a/module/contracts/api/accounts/connect/proto/saas/accounts/v1/installations.proto +++ b/module/contracts/api/accounts/connect/proto/saas/accounts/v1/installations.proto @@ -133,8 +133,14 @@ message InstallSolutionRequest { string root_scope_label = 6 [(buf.validate.field).string.max_len = 512]; // The least-privilege role granted to the agent at the root node. This is // deliberately NOT the full ":catalog" role; widening is a separate, - // audited admin action. - string role_id = 7 [(buf.validate.field).string.uuid = true]; + // audited admin action. Empty when the delivered module declares its own + // installation role: the host selects a named one, and "none" grants the + // agent no standing role at all. A role given for "none" is refused, and an + // empty one is refused for a target that declares no installation role. + string role_id = 7 [ + (buf.validate.field).ignore = IGNORE_IF_ZERO_VALUE, + (buf.validate.field).string.uuid = true + ]; // The accountable human of record. Must be a current org admin. Empty defaults // to the calling admin. string owner_principal_id = 8 [ diff --git a/module/contracts/api/catalog.codefly.json b/module/contracts/api/catalog.codefly.json index 1202431b6..e65f99eec 100644 --- a/module/contracts/api/catalog.codefly.json +++ b/module/contracts/api/catalog.codefly.json @@ -10,7 +10,7 @@ "kind": "protobuf", "package": "saas.accounts.v1", "path": "contracts/api/accounts/authority/contract.binpb", - "digest": "sha256:d6a4564228c0a8ee8957d48d0413c642500f03373d14a2ead4d64d0536bbcbe7", + "digest": "sha256:606e52170c2e55688ce163eb1d54c347f0d19ac40915d7fb6252f2034b787c0e", "services": [ { "name": "ModuleCapabilitiesService", @@ -66,7 +66,7 @@ "kind": "protobuf", "package": "", "path": "contracts/api/accounts/connect/contract.binpb", - "digest": "sha256:d6a4564228c0a8ee8957d48d0413c642500f03373d14a2ead4d64d0536bbcbe7", + "digest": "sha256:606e52170c2e55688ce163eb1d54c347f0d19ac40915d7fb6252f2034b787c0e", "services": [ { "name": "ArtifactService", diff --git a/module/module.package.codefly.yaml b/module/module.package.codefly.yaml index e115c475e..75742ac6a 100644 --- a/module/module.package.codefly.yaml +++ b/module/module.package.codefly.yaml @@ -18,12 +18,12 @@ services: kind: protobuf package: saas.accounts.v1 path: contracts/api/accounts/authority/contract.binpb - digest: sha256:d6a4564228c0a8ee8957d48d0413c642500f03373d14a2ead4d64d0536bbcbe7 + digest: sha256:606e52170c2e55688ce163eb1d54c347f0d19ac40915d7fb6252f2034b787c0e - endpoint: connect kind: protobuf package: "" path: contracts/api/accounts/connect/contract.binpb - digest: sha256:d6a4564228c0a8ee8957d48d0413c642500f03373d14a2ead4d64d0536bbcbe7 + digest: sha256:606e52170c2e55688ce163eb1d54c347f0d19ac40915d7fb6252f2034b787c0e - name: auth-gateway endpoints: [grpc, rest] api-contracts: diff --git a/module/services/accounts/code/pkg/adapters/host_interface_conformance_test.go b/module/services/accounts/code/pkg/adapters/host_interface_conformance_test.go index b1bad18d5..3689fd0c4 100644 --- a/module/services/accounts/code/pkg/adapters/host_interface_conformance_test.go +++ b/module/services/accounts/code/pkg/adapters/host_interface_conformance_test.go @@ -1164,9 +1164,32 @@ func runHostInterfaceFixture(t *testing.T) { AgentIdentifier: target.ReleasePublisher + "/" + target.ReleaseName + ":" + target.ReleaseVersion, DisplayName: selected, RootScopeLabel: selected, RoleID: catalogue, OwnerPrincipalID: conformanceAlex}) require.ErrorContains(t, err, "cannot override "+selected+"'s installation_role declaration") - installation, _, err := svc.InstallFixtureTarget(ctx, conformanceAlex, fixtureOrganization, selected, "", conformanceAlex) - require.NoError(t, err, "the declared role installs it") + // The administrator's install with no role, as the module page review + // sends it: the signed installation_role selects the standing role. + installation, err := svc.InstallSolution(ctx, conformanceAlex, &business.InstallSolutionParams{OrgID: fixtureOrganization, TargetID: target.TargetID, + AgentIdentifier: target.ReleasePublisher + "/" + target.ReleaseName + ":" + target.ReleaseVersion, DisplayName: selected, RootScopeLabel: selected, + OwnerPrincipalID: conformanceAlex}) + require.NoError(t, err, "an empty role installs a module page as its declared installation_role") require.NoError(t, completeInstallStep(ctx, store, installation)) + var standing []string + require.NoError(t, store.WithControlPlane(ctx, func(ctx context.Context) error { + rows, err := storetx.Tx(ctx).Query(ctx, `SELECT r.derived_from FROM public.scope_grants g JOIN public.roles r ON r.id = g.role_id + WHERE g.org_id = $1 AND g.subject_kind = 'principal' AND g.subject_id = $2`, fixtureOrganization, installation.GetAgentPrincipalId()) + if err != nil { + return err + } + defer rows.Close() + for rows.Next() { + var from string + if err := rows.Scan(&from); err != nil { + return err + } + standing = append(standing, from) + } + return rows.Err() + })) + require.Equal(t, []string{"org=" + fixtureOrganization + ";solution=" + target.TargetID + ";role=viewer"}, standing, + "the agent's one standing grant is the declared viewer role") }) // Last, because the envelope it writes becomes every module's ceiling: a diff --git a/module/services/accounts/code/pkg/adapters/install_solution_validation_test.go b/module/services/accounts/code/pkg/adapters/install_solution_validation_test.go new file mode 100644 index 000000000..4bf679456 --- /dev/null +++ b/module/services/accounts/code/pkg/adapters/install_solution_validation_test.go @@ -0,0 +1,22 @@ +package adapters + +import ( + "testing" + + gen "accounts/pkg/gen/saas/accounts/v1" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" +) + +// A module that declares its own installation role is installed with no +// caller role: the host selects a named one, and "none" grants none. A role +// that is given must still be a role id. +func TestInstallSolutionRequestRoleIsOptionalButWellFormed(t *testing.T) { + request := func(role string) *gen.InstallSolutionRequest { + return &gen.InstallSolutionRequest{OrgId: uuid.NewString(), AgentIdentifier: "publisher/name:1.0.0", TargetId: uuid.NewString(), RoleId: role} + } + require.NoError(t, Validate(request("")), "a declared installation role leaves the caller's role out") + require.NoError(t, Validate(request(uuid.NewString()))) + require.Error(t, Validate(request("viewer")), "a given role is a role id") +} diff --git a/module/services/accounts/code/pkg/business/installation_dynamic_test.go b/module/services/accounts/code/pkg/business/installation_dynamic_test.go index dd464e80b..4ef2cd036 100644 --- a/module/services/accounts/code/pkg/business/installation_dynamic_test.go +++ b/module/services/accounts/code/pkg/business/installation_dynamic_test.go @@ -208,7 +208,7 @@ func TestDynamicInstallationTenancyFiltersBeforePaginationAndInstall(t *testing. require.Empty(t, next) _, err = s.prepareInstallation(context.Background(), &InstallSolutionParams{OrgID: "org-a", TargetID: "1"}) require.Equal(t, codes.PermissionDenied, status.Code(err)) - _, err = s.prepareInstallation(context.Background(), &InstallSolutionParams{OrgID: "org-b", TargetID: "1"}) + _, err = s.prepareInstallation(context.Background(), &InstallSolutionParams{OrgID: "org-b", TargetID: "1", RoleID: "role-id"}) require.NoError(t, err) } diff --git a/module/services/accounts/code/pkg/business/installation_registry.go b/module/services/accounts/code/pkg/business/installation_registry.go index 9cb0e7cd4..58d548fb7 100644 --- a/module/services/accounts/code/pkg/business/installation_registry.go +++ b/module/services/accounts/code/pkg/business/installation_registry.go @@ -93,6 +93,11 @@ func (s *Service) prepareInstallation(ctx context.Context, params *InstallSoluti } params.RoleID = role } + // Only the delivered declaration may leave the caller's role out: a named + // installation_role is selected above, and "none" grants no standing role. + if !params.NoStandingAuthority && params.RoleID == "" { + return nil, NewStoreError(fmt.Errorf("role_id is required: %s declares no installation role for this host to select", target.RouteAlias), ErrTypeValidation) + } if params.Kind == "module" && target.Installed && params.OwnerPrincipalID == "" { existing, err := s.heldTargetInstallation(ctx, params.OrgID, params.TargetID) if err != nil { diff --git a/module/services/accounts/code/pkg/business/module_installation_selection_test.go b/module/services/accounts/code/pkg/business/module_installation_selection_test.go index 067fa5a76..9943918e7 100644 --- a/module/services/accounts/code/pkg/business/module_installation_selection_test.go +++ b/module/services/accounts/code/pkg/business/module_installation_selection_test.go @@ -24,6 +24,7 @@ func TestModuleNoStandingSelectionRequiresCurrentPairedDeclaration(t *testing.T) {"caller literal none refused", "none", "none", false, true, false}, {"old generation", "none", "", true, true, false}, {"legacy stays explicit", "", "role-id", false, false, false}, + {"legacy without a role refused", "", "", false, true, false}, {"named module cannot become catalogue role", "reader", "catalogue-role-id", false, true, false}, } { t.Run(tc.name, func(t *testing.T) { diff --git a/module/services/accounts/code/pkg/gen/saas/accounts/v1/installations.pb.go b/module/services/accounts/code/pkg/gen/saas/accounts/v1/installations.pb.go index b3216be77..747a170f8 100644 --- a/module/services/accounts/code/pkg/gen/saas/accounts/v1/installations.pb.go +++ b/module/services/accounts/code/pkg/gen/saas/accounts/v1/installations.pb.go @@ -566,7 +566,10 @@ type InstallSolutionRequest struct { RootScopeLabel string `protobuf:"bytes,6,opt,name=root_scope_label,json=rootScopeLabel,proto3" json:"root_scope_label,omitempty"` // The least-privilege role granted to the agent at the root node. This is // deliberately NOT the full ":catalog" role; widening is a separate, - // audited admin action. + // audited admin action. Empty when the delivered module declares its own + // installation role: the host selects a named one, and "none" grants the + // agent no standing role at all. A role given for "none" is refused, and an + // empty one is refused for a target that declares no installation role. RoleId string `protobuf:"bytes,7,opt,name=role_id,json=roleId,proto3" json:"role_id,omitempty"` // The accountable human of record. Must be a current org admin. Empty defaults // to the calling admin. @@ -2650,14 +2653,14 @@ const file_saas_accounts_v1_installations_proto_rawDesc = "" + "\vsetup_state\x18\x0f \x01(\tR\n" + "setupState\x12#\n" + "\rsetup_message\x18\x10 \x01(\tR\fsetupMessageB\r\n" + - "\v_revoked_atJ\x04\b\x04\x10\x05R\x13solution_identifier\"\xb9\x05\n" + + "\v_revoked_atJ\x04\b\x04\x10\x05R\x13solution_identifier\"\xbc\x05\n" + "\x16InstallSolutionRequest\x12\x1f\n" + "\x06org_id\x18\x01 \x01(\tB\b\xbaH\x05r\x03\xb0\x01\x01R\x05orgId\x125\n" + "\x10agent_identifier\x18\x02 \x01(\tB\n" + "\xbaH\ar\x05\x10\x01\x18\x80\x04R\x0fagentIdentifier\x12+\n" + "\fdisplay_name\x18\x04 \x01(\tB\b\xbaH\x05r\x03\x18\x80\x04R\vdisplayName\x122\n" + - "\x10root_scope_label\x18\x06 \x01(\tB\b\xbaH\x05r\x03\x18\x80\x04R\x0erootScopeLabel\x12!\n" + - "\arole_id\x18\a \x01(\tB\b\xbaH\x05r\x03\xb0\x01\x01R\x06roleId\x129\n" + + "\x10root_scope_label\x18\x06 \x01(\tB\b\xbaH\x05r\x03\x18\x80\x04R\x0erootScopeLabel\x12$\n" + + "\arole_id\x18\a \x01(\tB\v\xbaH\b\xd8\x01\x01r\x03\xb0\x01\x01R\x06roleId\x129\n" + "\x12owner_principal_id\x18\b \x01(\tB\v\xbaH\b\xd8\x01\x01r\x03\xb0\x01\x01R\x10ownerPrincipalId\x12D\n" + "\x16co_owner_principal_ids\x18\t \x03(\tB\x0f\xbaH\f\x92\x01\t\x10 \"\x05r\x03\xb0\x01\x01R\x13coOwnerPrincipalIds\x12<\n" + "\x11allowed_audiences\x18\n" + diff --git a/module/services/accounts/generated/openapi-raw/api.swagger.json b/module/services/accounts/generated/openapi-raw/api.swagger.json index 0e8987987..115c508ec 100644 --- a/module/services/accounts/generated/openapi-raw/api.swagger.json +++ b/module/services/accounts/generated/openapi-raw/api.swagger.json @@ -9341,7 +9341,7 @@ }, "roleId": { "type": "string", - "description": "The least-privilege role granted to the agent at the root node. This is\ndeliberately NOT the full \"\u003cnamespace\u003e:catalog\" role; widening is a separate,\naudited admin action." + "description": "The least-privilege role granted to the agent at the root node. This is\ndeliberately NOT the full \"\u003cnamespace\u003e:catalog\" role; widening is a separate,\naudited admin action. Empty when the delivered module declares its own\ninstallation role: the host selects a named one, and \"none\" grants the\nagent no standing role at all. A role given for \"none\" is refused, and an\nempty one is refused for a target that declares no installation role." }, "ownerPrincipalId": { "type": "string", diff --git a/module/services/accounts/openapi/api.swagger.json b/module/services/accounts/openapi/api.swagger.json index 404a52a3e..7450d6bd1 100644 --- a/module/services/accounts/openapi/api.swagger.json +++ b/module/services/accounts/openapi/api.swagger.json @@ -2552,7 +2552,7 @@ "type": "string" }, "roleId": { - "description": "The least-privilege role granted to the agent at the root node. This is\ndeliberately NOT the full \":catalog\" role; widening is a separate,\naudited admin action.", + "description": "The least-privilege role granted to the agent at the root node. This is\ndeliberately NOT the full \":catalog\" role; widening is a separate,\naudited admin action. Empty when the delivered module declares its own\ninstallation role: the host selects a named one, and \"none\" grants the\nagent no standing role at all. A role given for \"none\" is refused, and an\nempty one is refused for a target that declares no installation role.", "type": "string" }, "rootScopeLabel": { diff --git a/module/services/accounts/proto/saas/accounts/v1/installations.proto b/module/services/accounts/proto/saas/accounts/v1/installations.proto index 61af98331..44d793e14 100644 --- a/module/services/accounts/proto/saas/accounts/v1/installations.proto +++ b/module/services/accounts/proto/saas/accounts/v1/installations.proto @@ -133,8 +133,14 @@ message InstallSolutionRequest { string root_scope_label = 6 [(buf.validate.field).string.max_len = 512]; // The least-privilege role granted to the agent at the root node. This is // deliberately NOT the full ":catalog" role; widening is a separate, - // audited admin action. - string role_id = 7 [(buf.validate.field).string.uuid = true]; + // audited admin action. Empty when the delivered module declares its own + // installation role: the host selects a named one, and "none" grants the + // agent no standing role at all. A role given for "none" is refused, and an + // empty one is refused for a target that declares no installation role. + string role_id = 7 [ + (buf.validate.field).ignore = IGNORE_IF_ZERO_VALUE, + (buf.validate.field).string.uuid = true + ]; // The accountable human of record. Must be a current org admin. Empty defaults // to the calling admin. string owner_principal_id = 8 [ diff --git a/module/services/frontend/AGENTS.md b/module/services/frontend/AGENTS.md index db165f314..8a22db13e 100644 --- a/module/services/frontend/AGENTS.md +++ b/module/services/frontend/AGENTS.md @@ -471,7 +471,19 @@ not replace the cached operation; retries retain it, and Review again explicitly requests new consent. Pending installation rows offer current-person continuation. Supporting modules remain visible on `/admin/modules` and in installation detail. That administrator path remains available, but is not a step in normal -solution installation. +solution installation. There, which form Install opens is the authority's +answer, not the target's kind (`useRolesDeclared`): a module page whose offer +names roles is reviewed from that offer (its machine access and what each role +permits), installed through `InstallSolution` with no agent role, because its +signed `installation_role` selects one (or none), and then opened at its Setup +page, where each role takes a team. The shared core installs only solutions, so +a module page stays this host's installation and carries no consent tuple. A +module whose offer the authority refuses NotFound (it declares no roles) keeps +the agent-role form; any other failure of that read is shown with a retry, never +taken as "no roles". The review's approval is the browser's: a module page's +offer carries no consent digest for the host to check it against. On Setup, a +module page's roles take teams through the shared role grant form; the staged +team-per-role picker binds to a solution's consent digest and is not offered. The only installer configuration is a team for each role the solution's offer declares: an existing organization team, or one created inline through diff --git a/module/services/frontend/code/package-lock.json b/module/services/frontend/code/package-lock.json index 855a9df6b..8f9369bb3 100644 --- a/module/services/frontend/code/package-lock.json +++ b/module/services/frontend/code/package-lock.json @@ -15,7 +15,7 @@ "@bufbuild/protobuf": "^2.11.0", "@codefly-dev/saas-plugin-contract": "2.4.4", "@codefly-dev/saas-plugin-react": "0.4.5", - "@codefly-dev/saas-sdk": "0.3.41", + "@codefly-dev/saas-sdk": "0.3.42", "@codefly-dev/saas-ui": "0.13.0", "@codefly-dev/ui": "0.13.0", "@codefly/saas-plugin-manifest": "0.1.2", @@ -15742,7 +15742,7 @@ }, "packages/saas-sdk": { "name": "@codefly-dev/saas-sdk", - "version": "0.3.41", + "version": "0.3.42", "license": "Apache-2.0", "dependencies": { "@bufbuild/protobuf": "^2.11.0", diff --git a/module/services/frontend/code/package.json b/module/services/frontend/code/package.json index 5c4fce115..9c722c0c9 100644 --- a/module/services/frontend/code/package.json +++ b/module/services/frontend/code/package.json @@ -39,7 +39,7 @@ "@bufbuild/protobuf": "^2.11.0", "@codefly-dev/saas-plugin-contract": "2.4.4", "@codefly-dev/saas-plugin-react": "0.4.5", - "@codefly-dev/saas-sdk": "0.3.41", + "@codefly-dev/saas-sdk": "0.3.42", "@codefly-dev/saas-ui": "0.13.0", "@codefly-dev/ui": "0.13.0", "@codefly/saas-plugin-manifest": "0.1.2", diff --git a/module/services/frontend/code/packages/saas-sdk/generated/library.codefly.yaml b/module/services/frontend/code/packages/saas-sdk/generated/library.codefly.yaml index 7841419b6..c71813471 100644 --- a/module/services/frontend/code/packages/saas-sdk/generated/library.codefly.yaml +++ b/module/services/frontend/code/packages/saas-sdk/generated/library.codefly.yaml @@ -12,7 +12,7 @@ sources: module: saas-starter service: accounts endpoint: connect - contract-digest: sha256:d6a4564228c0a8ee8957d48d0413c642500f03373d14a2ead4d64d0536bbcbe7 + contract-digest: sha256:606e52170c2e55688ce163eb1d54c347f0d19ac40915d7fb6252f2034b787c0e services: - AccessibleScopeService - AuditService diff --git a/module/services/frontend/code/packages/saas-sdk/generated/typescript/src/gen/saas/accounts/v1/installations_pb.ts b/module/services/frontend/code/packages/saas-sdk/generated/typescript/src/gen/saas/accounts/v1/installations_pb.ts index 6552c6c36..abe997559 100644 --- a/module/services/frontend/code/packages/saas-sdk/generated/typescript/src/gen/saas/accounts/v1/installations_pb.ts +++ b/module/services/frontend/code/packages/saas-sdk/generated/typescript/src/gen/saas/accounts/v1/installations_pb.ts @@ -19,7 +19,7 @@ import type { Message } from "@bufbuild/protobuf"; * Describes the file saas/accounts/v1/installations.proto. */ export const file_saas_accounts_v1_installations: GenFile = /*@__PURE__*/ - fileDesc("CiRzYWFzL2FjY291bnRzL3YxL2luc3RhbGxhdGlvbnMucHJvdG8SEHNhYXMuYWNjb3VudHMudjEitwQKDEluc3RhbGxhdGlvbhIUCgJpZBgBIAEoCUIIukgFcgOwAQESGAoGb3JnX2lkGAIgASgJQgi6SAVyA7ABARIkChJhZ2VudF9wcmluY2lwYWxfaWQYAyABKAlCCLpIBXIDsAEBEiQKEm93bmVyX3ByaW5jaXBhbF9pZBgFIAEoCUIIukgFcgOwAQESHgoWY29fb3duZXJfcHJpbmNpcGFsX2lkcxgGIAMoCRIkChJyb290X3Njb3BlX25vZGVfaWQYByABKAlCCLpIBXIDsAEBEjQKBnN0YXR1cxgIIAEoDjIkLnNhYXMuYWNjb3VudHMudjEuSW5zdGFsbGF0aW9uU3RhdHVzEi4KCmNyZWF0ZWRfYXQYCSABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEjMKCnJldm9rZWRfYXQYCiABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wSACIAQESHgoJdGFyZ2V0X2lkGAsgASgJQgu6SAjYAQFyA7ABARIgCg5yZXZva2VkX3JlYXNvbhgMIAEoCUIIukgFcgMYgAQSIAoOZmFpbHVyZV9yZWFzb24YDSABKAlCCLpIBXIDGIAEEhAKCHJldmlzaW9uGA4gASgEEhMKC3NldHVwX3N0YXRlGA8gASgJEhUKDXNldHVwX21lc3NhZ2UYECABKAlCDQoLX3Jldm9rZWRfYXRKBAgEEAVSE3NvbHV0aW9uX2lkZW50aWZpZXIimQQKFkluc3RhbGxTb2x1dGlvblJlcXVlc3QSGAoGb3JnX2lkGAEgASgJQgi6SAVyA7ABARIkChBhZ2VudF9pZGVudGlmaWVyGAIgASgJQgq6SAdyBRABGIAEEh4KDGRpc3BsYXlfbmFtZRgEIAEoCUIIukgFcgMYgAQSIgoQcm9vdF9zY29wZV9sYWJlbBgGIAEoCUIIukgFcgMYgAQSGQoHcm9sZV9pZBgHIAEoCUIIukgFcgOwAQESJwoSb3duZXJfcHJpbmNpcGFsX2lkGAggASgJQgu6SAjYAQFyA7ABARIvChZjb19vd25lcl9wcmluY2lwYWxfaWRzGAkgAygJQg+6SAySAQkQICIFcgOwAQESKgoRYWxsb3dlZF9hdWRpZW5jZXMYCiADKAlCD7pIDJIBCRBAIgVyAxiABBInCg5hbGxvd2VkX3Njb3BlcxgLIAMoCUIPukgMkgEJEEAiBXIDGIABEhsKCXRhcmdldF9pZBgMIAEoCUIIukgFcgOwAQESHwoFc2NvcGUYDSABKAlCC7pICHIGEAIogIBASACIAQESNwoIYXBwcm92YWwYDiABKAsyJS5ob3N0Lmluc3RhbGxhdGlvbi52MS5Db25zZW50QXBwcm92YWxCCAoGX3Njb3BlSgQIAxAESgQIBRAGUhNzb2x1dGlvbl9pZGVudGlmaWVyUg9yb290X3Njb3BlX3BhdGgidAoZTmFycm93SW5zdGFsbGF0aW9uUmVxdWVzdBIYCgZvcmdfaWQYASABKAlCCLpIBXIDsAEBEiEKD2luc3RhbGxhdGlvbl9pZBgCIAEoCUIIukgFcgOwAQESGgoFc2NvcGUYAyABKAlCC7pICHIGEAIogIBAIlcKGFJldHJ5SW5zdGFsbGF0aW9uUmVxdWVzdBIYCgZvcmdfaWQYASABKAlCCLpIBXIDsAEBEiEKD2luc3RhbGxhdGlvbl9pZBgCIAEoCUIIukgFcgOwAQEiVwoYVW5pbnN0YWxsU29sdXRpb25SZXF1ZXN0EhgKBm9yZ19pZBgBIAEoCUIIukgFcgOwAQESIQoPaW5zdGFsbGF0aW9uX2lkGAIgASgJQgi6SAVyA7ABASK+AQokVHJhbnNmZXJJbnN0YWxsYXRpb25Pd25lcnNoaXBSZXF1ZXN0EhgKBm9yZ19pZBgBIAEoCUIIukgFcgOwAQESIQoPaW5zdGFsbGF0aW9uX2lkGAIgASgJQgi6SAVyA7ABARIoChZuZXdfb3duZXJfcHJpbmNpcGFsX2lkGAMgASgJQgi6SAVyA7ABARIvChZjb19vd25lcl9wcmluY2lwYWxfaWRzGAQgAygJQg+6SAySAQkQICIFcgOwAQEiVQoWR2V0SW5zdGFsbGF0aW9uUmVxdWVzdBIYCgZvcmdfaWQYASABKAlCCLpIBXIDsAEBEiEKD2luc3RhbGxhdGlvbl9pZBgCIAEoCUIIukgFcgOwAQEihQEKF0dldEluc3RhbGxhdGlvblJlc3BvbnNlEjQKDGluc3RhbGxhdGlvbhgBIAEoCzIeLnNhYXMuYWNjb3VudHMudjEuSW5zdGFsbGF0aW9uEjQKBmhlYWx0aBgCIAEoDjIkLnNhYXMuYWNjb3VudHMudjEuSW5zdGFsbGF0aW9uSGVhbHRoIp0BChhMaXN0SW5zdGFsbGF0aW9uc1JlcXVlc3QSGAoGb3JnX2lkGAEgASgJQgi6SAVyA7ABARI0CgZzdGF0dXMYAiABKA4yJC5zYWFzLmFjY291bnRzLnYxLkluc3RhbGxhdGlvblN0YXR1cxIdCglwYWdlX3NpemUYAyABKAVCCrpIBxoFGPQDKAASEgoKcGFnZV90b2tlbhgEIAEoCSKBAQoTSW5zdGFsbGF0aW9uU3VtbWFyeRI0CgxpbnN0YWxsYXRpb24YASABKAsyHi5zYWFzLmFjY291bnRzLnYxLkluc3RhbGxhdGlvbhI0CgZoZWFsdGgYAiABKA4yJC5zYWFzLmFjY291bnRzLnYxLkluc3RhbGxhdGlvbkhlYWx0aCJyChlMaXN0SW5zdGFsbGF0aW9uc1Jlc3BvbnNlEjwKDWluc3RhbGxhdGlvbnMYASADKAsyJS5zYWFzLmFjY291bnRzLnYxLkluc3RhbGxhdGlvblN1bW1hcnkSFwoPbmV4dF9wYWdlX3Rva2VuGAIgASgJIq0CChdSZXZpZXdlZFNvbHV0aW9uSW5zdGFsbBIOCgZvcmdfaWQYASABKAkSEAoIYWN0b3JfaWQYAiABKAkSEQoJdGFyZ2V0X2lkGAMgASgJEg4KBnRhcmdldBgEIAEoCRISCgpiaW5kaW5nX2lkGAUgASgJEg8KB3JlbGVhc2UYBiABKAkSFwoPZGVsaXZlcnlfZGlnZXN0GAcgASgJEjcKCGFwcHJvdmFsGAggASgLMiUuaG9zdC5pbnN0YWxsYXRpb24udjEuQ29uc2VudEFwcHJvdmFsEhkKEWluc3RhbGxhdGlvbl9yb2xlGAkgASgJEjsKB21vZHVsZXMYCiADKAsyKi5zYWFzLmFjY291bnRzLnYxLlJldmlld2VkU3VwcG9ydGluZ01vZHVsZSKiAgoYUmV2aWV3ZWRTdXBwb3J0aW5nTW9kdWxlEhEKCXByaW5jaXBhbBgBIAEoCRIRCgl0YXJnZXRfaWQYAiABKAkSDgoGdGFyZ2V0GAMgASgJEhIKCmJpbmRpbmdfaWQYBCABKAkSDwoHcmVsZWFzZRgFIAEoCRIXCg9kZWxpdmVyeV9kaWdlc3QYBiABKAkSGQoRaW5zdGFsbGF0aW9uX3JvbGUYByABKAkSHQoVcmV1c2VfaW5zdGFsbGF0aW9uX2lkGAggASgJEg0KBXNjb3BlGAkgASgJEjcKCGFwcHJvdmFsGAogASgLMiUuaG9zdC5pbnN0YWxsYXRpb24udjEuQ29uc2VudEFwcHJvdmFsEhAKCHJlcXVpcmVkGAsgASgIIlUKHFJldmlld1NvbHV0aW9uSW5zdGFsbFJlcXVlc3QSGAoGb3JnX2lkGAEgASgJQgi6SAVyA7ABARIbCgl0YXJnZXRfaWQYAiABKAlCCLpIBXIDsAEBIn0KHkluc3RhbGxSZXZpZXdlZFNvbHV0aW9uUmVxdWVzdBIYCgZvcmdfaWQYASABKAlCCLpIBXIDsAEBEkEKBnJldmlldxgCIAEoCzIpLnNhYXMuYWNjb3VudHMudjEuUmV2aWV3ZWRTb2x1dGlvbkluc3RhbGxCBrpIA8gBASJzCiRMaXN0T3JnYW5pemF0aW9uSW5zdGFsbGF0aW9uc1JlcXVlc3QSGAoGb3JnX2lkGAEgASgJQgi6SAVyA7ABARIdCglwYWdlX3NpemUYAiABKAVCCrpIBxoFGPQDKAASEgoKcGFnZV90b2tlbhgDIAEoCSLIAwoYT3JnYW5pemF0aW9uSW5zdGFsbGF0aW9uEjQKDGluc3RhbGxhdGlvbhgBIAEoCzIeLnNhYXMuYWNjb3VudHMudjEuSW5zdGFsbGF0aW9uEjQKBmhlYWx0aBgCIAEoDjIkLnNhYXMuYWNjb3VudHMudjEuSW5zdGFsbGF0aW9uSGVhbHRoEjMKBnRhcmdldBgDIAEoCzIjLnNhYXMuYWNjb3VudHMudjEuQXZhaWxhYmxlU29sdXRpb24SFwoPcm9vdF9zY29wZV9wYXRoGAQgASgJEjwKDWdyYW50ZWRfdGVhbXMYBSADKAsyJS5zYWFzLmFjY291bnRzLnYxLkNvbGxlY3Rpb25SZWFkR3JhbnQSPgoPaW5oZXJpdGVkX3RlYW1zGAYgAygLMiUuc2Fhcy5hY2NvdW50cy52MS5Db2xsZWN0aW9uUmVhZEdyYW50EjoKCXJlYWRpbmVzcxgHIAEoCzInLnNhYXMuYWNjb3VudHMudjEuSW5zdGFsbGF0aW9uUmVhZGluZXNzEjgKD3JlYWNoZWRfbW9kdWxlcxgIIAMoCzIfLnNhYXMuYWNjb3VudHMudjEuUmVhY2hlZE1vZHVsZSLtAQoSUmVhZGluZXNzQ29uZGl0aW9uEjYKBGtpbmQYASABKA4yKC5zYWFzLmFjY291bnRzLnYxLlJlYWRpbmVzc0NvbmRpdGlvbktpbmQSDwoHc3ViamVjdBgCIAEoCRIOCgZkZXRhaWwYAyABKAkSOAoKY2xlYXJlZF9ieRgEIAEoDjIkLnNhYXMuYWNjb3VudHMudjEuUmVhZGluZXNzQ2xlYXJlZEJ5EjEKBmFjdGlvbhgFIAEoDjIhLnNhYXMuYWNjb3VudHMudjEuUmVhZGluZXNzQWN0aW9uEhEKCXNvdXJjZV9pZBgGIAEoCSJbChVJbnN0YWxsYXRpb25SZWFkaW5lc3MSDQoFcmVhZHkYASABKAgSMwoFdW5tZXQYAiADKAsyJC5zYWFzLmFjY291bnRzLnYxLlJlYWRpbmVzc0NvbmRpdGlvbiJPCg1SZWFjaGVkTW9kdWxlEhAKCGF1ZGllbmNlGAEgASgJEhcKD2luc3RhbGxhdGlvbl9pZBgCIAEoCRITCgtyb3V0ZV9hbGlhcxgDIAEoCSKDAQolTGlzdE9yZ2FuaXphdGlvbkluc3RhbGxhdGlvbnNSZXNwb25zZRJBCg1pbnN0YWxsYXRpb25zGAEgAygLMiouc2Fhcy5hY2NvdW50cy52MS5Pcmdhbml6YXRpb25JbnN0YWxsYXRpb24SFwoPbmV4dF9wYWdlX3Rva2VuGAIgASgJIpMBCh5HcmFudEluc3RhbGxhdGlvblRvVGVhbVJlcXVlc3QSGAoGb3JnX2lkGAEgASgJQgi6SAVyA7ABARIhCg9pbnN0YWxsYXRpb25faWQYAiABKAlCCLpIBXIDsAEBEhkKB3RlYW1faWQYAyABKAlCCLpIBXIDsAEBEhkKB3JvbGVfaWQYBCABKAlCCLpIBXIDsAEBInUKE0NhcnJpZWRNb2R1bGVBY2Nlc3MSEAoIYXVkaWVuY2UYASABKAkSFwoPaW5zdGFsbGF0aW9uX2lkGAIgASgJEhIKCnNjb3BlX3BhdGgYAyABKAkSDwoHcm9sZV9pZBgEIAEoCRIOCgZzY29wZXMYBSADKAkiuwEKH0dyYW50SW5zdGFsbGF0aW9uVG9UZWFtUmVzcG9uc2USKwoFZ3JhbnQYASABKAsyHC5zYWFzLmFjY291bnRzLnYxLlNjb3BlR3JhbnQSNgoHY2FycmllZBgCIAMoCzIlLnNhYXMuYWNjb3VudHMudjEuQ2FycmllZE1vZHVsZUFjY2VzcxIzCgV1bm1ldBgDIAMoCzIkLnNhYXMuYWNjb3VudHMudjEuUmVhZGluZXNzQ29uZGl0aW9uIpYBCiFSZXZva2VJbnN0YWxsYXRpb25Gcm9tVGVhbVJlcXVlc3QSGAoGb3JnX2lkGAEgASgJQgi6SAVyA7ABARIhCg9pbnN0YWxsYXRpb25faWQYAiABKAlCCLpIBXIDsAEBEhkKB3RlYW1faWQYAyABKAlCCLpIBXIDsAEBEhkKB3JvbGVfaWQYBCABKAlCCLpIBXIDsAEBIoQCChFBdmFpbGFibGVTb2x1dGlvbhIbCgl0YXJnZXRfaWQYASABKAlCCLpIBXIDsAEBEhIKCmJpbmRpbmdfaWQYAiABKAkSEwoLcm91dGVfYWxpYXMYAyABKAkSGQoRcmVsZWFzZV9wdWJsaXNoZXIYBCABKAkSFAoMcmVsZWFzZV9uYW1lGAUgASgJEhcKD3JlbGVhc2VfdmVyc2lvbhgGIAEoCRIZChFvcGVuZWRfZ2VuZXJhdGlvbhgHIAEoBBIaChJhcHBsaWVkX2dlbmVyYXRpb24YCCABKAQSEQoJaW5zdGFsbGVkGAkgASgIEhUKBGtpbmQYCiABKAlCB7pIBHICGCAibAodTGlzdEF2YWlsYWJsZVNvbHV0aW9uc1JlcXVlc3QSGAoGb3JnX2lkGAEgASgJQgi6SAVyA7ABARIdCglwYWdlX3NpemUYAiABKAVCCrpIBxoFGPQDKAASEgoKcGFnZV90b2tlbhgDIAEoCSJxCh5MaXN0QXZhaWxhYmxlU29sdXRpb25zUmVzcG9uc2USNgoJc29sdXRpb25zGAEgAygLMiMuc2Fhcy5hY2NvdW50cy52MS5BdmFpbGFibGVTb2x1dGlvbhIXCg9uZXh0X3BhZ2VfdG9rZW4YAiABKAkquwEKEkluc3RhbGxhdGlvblN0YXR1cxIjCh9JTlNUQUxMQVRJT05fU1RBVFVTX1VOU1BFQ0lGSUVEEAASHgoaSU5TVEFMTEFUSU9OX1NUQVRVU19BQ1RJVkUQARIfChtJTlNUQUxMQVRJT05fU1RBVFVTX1JFVk9LRUQQAhIfChtJTlNUQUxMQVRJT05fU1RBVFVTX1BFTkRJTkcQAxIeChpJTlNUQUxMQVRJT05fU1RBVFVTX0ZBSUxFRBAEKoQCChJJbnN0YWxsYXRpb25IZWFsdGgSIwofSU5TVEFMTEFUSU9OX0hFQUxUSF9VTlNQRUNJRklFRBAAEh8KG0lOU1RBTExBVElPTl9IRUFMVEhfSEVBTFRIWRABEikKJUlOU1RBTExBVElPTl9IRUFMVEhfTk9fRUxJR0lCTEVfT1dORVIQAhIlCiFJTlNUQUxMQVRJT05fSEVBTFRIX0FHRU5UX1JFVk9LRUQQAxImCiJJTlNUQUxMQVRJT05fSEVBTFRIX0FHRU5UX0RJU0FCTEVEEAQSLgoqSU5TVEFMTEFUSU9OX0hFQUxUSF9TVEFORElOR19HUkFOVF9NSVNTSU5HEAUq8wMKFlJlYWRpbmVzc0NvbmRpdGlvbktpbmQSKAokUkVBRElORVNTX0NPTkRJVElPTl9LSU5EX1VOU1BFQ0lGSUVEEAASMQotUkVBRElORVNTX0NPTkRJVElPTl9LSU5EX0lOU1RBTExfU1RFUF9QRU5ESU5HEAESMAosUkVBRElORVNTX0NPTkRJVElPTl9LSU5EX0lOU1RBTExfU1RFUF9GQUlMRUQQAhItCilSRUFESU5FU1NfQ09ORElUSU9OX0tJTkRfUkVDT1JEX1VOSEVBTFRIWRADEjUKMVJFQURJTkVTU19DT05ESVRJT05fS0lORF9NT0RVTEVfUFJJTkNJUEFMX09NSVRURUQQBBI2CjJSRUFESU5FU1NfQ09ORElUSU9OX0tJTkRfT1BFUkFUSU9OX0JJTkRJTkdfT01JVFRFRBAFEjEKLVJFQURJTkVTU19DT05ESVRJT05fS0lORF9TT1VSQ0VfTk9UX0RFTEVHQVRFRBAGEjkKNVJFQURJTkVTU19DT05ESVRJT05fS0lORF9SRUFDSEVEX01PRFVMRV9OT1RfSU5TVEFMTEVEEAcSPgo6UkVBRElORVNTX0NPTkRJVElPTl9LSU5EX0NBUlJJRURfTU9EVUxFX0FDQ0VTU19OT1RfREVSSVZFRBAIKtUBChJSZWFkaW5lc3NDbGVhcmVkQnkSJAogUkVBRElORVNTX0NMRUFSRURfQllfVU5TUEVDSUZJRUQQABIfChtSRUFESU5FU1NfQ0xFQVJFRF9CWV9WSUVXRVIQARIrCidSRUFESU5FU1NfQ0xFQVJFRF9CWV9PUkdBTklaQVRJT05fQURNSU4QAhIqCiZSRUFESU5FU1NfQ0xFQVJFRF9CWV9QTEFURk9STV9PUEVSQVRPUhADEh8KG1JFQURJTkVTU19DTEVBUkVEX0JZX05PQk9EWRAEKp8CCg9SZWFkaW5lc3NBY3Rpb24SIAocUkVBRElORVNTX0FDVElPTl9VTlNQRUNJRklFRBAAEikKJVJFQURJTkVTU19BQ1RJT05fTk9ORV9BVF9USElTX1NVUkZBQ0UQARInCiNSRUFESU5FU1NfQUNUSU9OX1JFVFJZX0lOU1RBTExfU1RFUBACEiUKIVJFQURJTkVTU19BQ1RJT05fUkVDT05ORUNUX1NPVVJDRRADEicKI1JFQURJTkVTU19BQ1RJT05fVFJBTlNGRVJfT1dORVJTSElQEAQSKwonUkVBRElORVNTX0FDVElPTl9JTlNUQUxMX1JFQUNIRURfTU9EVUxFEAUSGQoVUkVBRElORVNTX0FDVElPTl9XQUlUEAYyrhUKE0luc3RhbGxhdGlvblNlcnZpY2USxgEKFVJldmlld1NvbHV0aW9uSW5zdGFsbBIuLnNhYXMuYWNjb3VudHMudjEuUmV2aWV3U29sdXRpb25JbnN0YWxsUmVxdWVzdBopLnNhYXMuYWNjb3VudHMudjEuUmV2aWV3ZWRTb2x1dGlvbkluc3RhbGwiUsLzGCIIAhAEKgwKBm9yZ19pZBACGAEwAToCEAFAAUgDUANYA2ABgtPkkwImOgEqIiEvdjEvc29sdXRpb24taW5zdGFsbGF0aW9ucy9yZXZpZXcSuAEKF0luc3RhbGxSZXZpZXdlZFNvbHV0aW9uEjAuc2Fhcy5hY2NvdW50cy52MS5JbnN0YWxsUmV2aWV3ZWRTb2x1dGlvblJlcXVlc3QaHi5zYWFzLmFjY291bnRzLnYxLkluc3RhbGxhdGlvbiJLwvMYIggCEAQqDAoGb3JnX2lkEAIYATABOgIQAUABSARQA1gDYAGC0+STAh86ASoiGi92MS9zb2x1dGlvbi1pbnN0YWxsYXRpb25zEroBCg9JbnN0YWxsU29sdXRpb24SKC5zYWFzLmFjY291bnRzLnYxLkluc3RhbGxTb2x1dGlvblJlcXVlc3QaHi5zYWFzLmFjY291bnRzLnYxLkluc3RhbGxhdGlvbiJdwvMYPQgCEAQqDAoGb3JnX2lkEAIYATABOh0KGXNhYXMuaW5zdGFsbGF0aW9uLmNyZWF0ZWQQAkABSARQA1gDYAGC0+STAhY6ASoiES92MS9pbnN0YWxsYXRpb25zEtIBChFVbmluc3RhbGxTb2x1dGlvbhIqLnNhYXMuYWNjb3VudHMudjEuVW5pbnN0YWxsU29sdXRpb25SZXF1ZXN0GhYuZ29vZ2xlLnByb3RvYnVmLkVtcHR5InnC8xg9CAIQBCoMCgZvcmdfaWQQAhgBMAE6HQoZc2Fhcy5pbnN0YWxsYXRpb24ucmV2b2tlZBACQAFIBFADWANgAYLT5JMCMjoBKiItL3YxL2luc3RhbGxhdGlvbnMve2luc3RhbGxhdGlvbl9pZH06dW5pbnN0YWxsEtIBChJOYXJyb3dJbnN0YWxsYXRpb24SKy5zYWFzLmFjY291bnRzLnYxLk5hcnJvd0luc3RhbGxhdGlvblJlcXVlc3QaFi5nb29nbGUucHJvdG9idWYuRW1wdHkid8LzGD4IAhAEKgwKBm9yZ19pZBACGAEwAToeChpzYWFzLmluc3RhbGxhdGlvbi5uYXJyb3dlZBACQAFIBFADWANgAYLT5JMCLzoBKiIqL3YxL2luc3RhbGxhdGlvbnMve2luc3RhbGxhdGlvbl9pZH06bmFycm93EtYBChFSZXRyeUluc3RhbGxhdGlvbhIqLnNhYXMuYWNjb3VudHMudjEuUmV0cnlJbnN0YWxsYXRpb25SZXF1ZXN0Gh4uc2Fhcy5hY2NvdW50cy52MS5JbnN0YWxsYXRpb24idcLzGD0IAhAEKgwKBm9yZ19pZBACGAEwATodChlzYWFzLmluc3RhbGxhdGlvbi5yZXRyaWVkEAJAAUgEUANYA2ABgtPkkwIuOgEqIikvdjEvaW5zdGFsbGF0aW9ucy97aW5zdGFsbGF0aW9uX2lkfTpyZXRyeRKJAgodVHJhbnNmZXJJbnN0YWxsYXRpb25Pd25lcnNoaXASNi5zYWFzLmFjY291bnRzLnYxLlRyYW5zZmVySW5zdGFsbGF0aW9uT3duZXJzaGlwUmVxdWVzdBoeLnNhYXMuYWNjb3VudHMudjEuSW5zdGFsbGF0aW9uIo8BwvMYSwgCEAQqDAoGb3JnX2lkEAIYATABOisKJ3NhYXMuaW5zdGFsbGF0aW9uLm93bmVyc2hpcF90cmFuc2ZlcnJlZBACQAFIBFADWANgAYLT5JMCOjoBKiI1L3YxL2luc3RhbGxhdGlvbnMve2luc3RhbGxhdGlvbl9pZH06dHJhbnNmZXJPd25lcnNoaXASuQEKD0dldEluc3RhbGxhdGlvbhIoLnNhYXMuYWNjb3VudHMudjEuR2V0SW5zdGFsbGF0aW9uUmVxdWVzdBopLnNhYXMuYWNjb3VudHMudjEuR2V0SW5zdGFsbGF0aW9uUmVzcG9uc2UiUcLzGCIIAhADKgwKBm9yZ19pZBACGAEwAToCEAFAAUgDUANYA2ABgtPkkwIlEiMvdjEvaW5zdGFsbGF0aW9ucy97aW5zdGFsbGF0aW9uX2lkfRKGAQoRTGlzdEluc3RhbGxhdGlvbnMSKi5zYWFzLmFjY291bnRzLnYxLkxpc3RJbnN0YWxsYXRpb25zUmVxdWVzdBorLnNhYXMuYWNjb3VudHMudjEuTGlzdEluc3RhbGxhdGlvbnNSZXNwb25zZSIYwvMYFAgDEAEwAToCEAFAAUgHUANYA2ABEsoBChZMaXN0QXZhaWxhYmxlU29sdXRpb25zEi8uc2Fhcy5hY2NvdW50cy52MS5MaXN0QXZhaWxhYmxlU29sdXRpb25zUmVxdWVzdBowLnNhYXMuYWNjb3VudHMudjEuTGlzdEF2YWlsYWJsZVNvbHV0aW9uc1Jlc3BvbnNlIk3C8xgiCAIQBCoMCgZvcmdfaWQQAhgBMAE6AhABQAFIA1ADWANgAYLT5JMCIRIfL3YxL2luc3RhbGxhdGlvbnM6bGlzdEF2YWlsYWJsZRLRAQodTGlzdE9yZ2FuaXphdGlvbkluc3RhbGxhdGlvbnMSNi5zYWFzLmFjY291bnRzLnYxLkxpc3RPcmdhbml6YXRpb25JbnN0YWxsYXRpb25zUmVxdWVzdBo3LnNhYXMuYWNjb3VudHMudjEuTGlzdE9yZ2FuaXphdGlvbkluc3RhbGxhdGlvbnNSZXNwb25zZSI/wvMYIggCEAQqDAoGb3JnX2lkEAIYATABOgIQAUABSANQA1gDYAGC0+STAhMSES92MS9pbnN0YWxsYXRpb25zEvABChdHcmFudEluc3RhbGxhdGlvblRvVGVhbRIwLnNhYXMuYWNjb3VudHMudjEuR3JhbnRJbnN0YWxsYXRpb25Ub1RlYW1SZXF1ZXN0GjEuc2Fhcy5hY2NvdW50cy52MS5HcmFudEluc3RhbGxhdGlvblRvVGVhbVJlc3BvbnNlInDC8xg4CAIQBCoMCgZvcmdfaWQQAhgBMAE6FgoSc2Fhcy5zY29wZS5ncmFudGVkEAJAAUgEUANYA2ABeAKC0+STAi46ASoiKS92MS9pbnN0YWxsYXRpb25zL3tpbnN0YWxsYXRpb25faWR9L3RlYW1zEu0BChpSZXZva2VJbnN0YWxsYXRpb25Gcm9tVGVhbRIzLnNhYXMuYWNjb3VudHMudjEuUmV2b2tlSW5zdGFsbGF0aW9uRnJvbVRlYW1SZXF1ZXN0GhYuZ29vZ2xlLnByb3RvYnVmLkVtcHR5IoEBwvMYOAgCEAQqDAoGb3JnX2lkEAIYATABOhYKEnNhYXMuc2NvcGUucmV2b2tlZBACQAFIBFADWANgAXgCgtPkkwI/OgEqIjovdjEvaW5zdGFsbGF0aW9ucy97aW5zdGFsbGF0aW9uX2lkfS90ZWFtcy97dGVhbV9pZH06cmV2b2tlYgZwcm90bzM", [file_buf_validate_validate, file_google_api_annotations, file_google_protobuf_empty, file_host_installation_v1_installation, file_google_protobuf_timestamp, file_saas_accounts_v1_authorization, file_saas_policy_v1_options]); + fileDesc("CiRzYWFzL2FjY291bnRzL3YxL2luc3RhbGxhdGlvbnMucHJvdG8SEHNhYXMuYWNjb3VudHMudjEitwQKDEluc3RhbGxhdGlvbhIUCgJpZBgBIAEoCUIIukgFcgOwAQESGAoGb3JnX2lkGAIgASgJQgi6SAVyA7ABARIkChJhZ2VudF9wcmluY2lwYWxfaWQYAyABKAlCCLpIBXIDsAEBEiQKEm93bmVyX3ByaW5jaXBhbF9pZBgFIAEoCUIIukgFcgOwAQESHgoWY29fb3duZXJfcHJpbmNpcGFsX2lkcxgGIAMoCRIkChJyb290X3Njb3BlX25vZGVfaWQYByABKAlCCLpIBXIDsAEBEjQKBnN0YXR1cxgIIAEoDjIkLnNhYXMuYWNjb3VudHMudjEuSW5zdGFsbGF0aW9uU3RhdHVzEi4KCmNyZWF0ZWRfYXQYCSABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEjMKCnJldm9rZWRfYXQYCiABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wSACIAQESHgoJdGFyZ2V0X2lkGAsgASgJQgu6SAjYAQFyA7ABARIgCg5yZXZva2VkX3JlYXNvbhgMIAEoCUIIukgFcgMYgAQSIAoOZmFpbHVyZV9yZWFzb24YDSABKAlCCLpIBXIDGIAEEhAKCHJldmlzaW9uGA4gASgEEhMKC3NldHVwX3N0YXRlGA8gASgJEhUKDXNldHVwX21lc3NhZ2UYECABKAlCDQoLX3Jldm9rZWRfYXRKBAgEEAVSE3NvbHV0aW9uX2lkZW50aWZpZXIinAQKFkluc3RhbGxTb2x1dGlvblJlcXVlc3QSGAoGb3JnX2lkGAEgASgJQgi6SAVyA7ABARIkChBhZ2VudF9pZGVudGlmaWVyGAIgASgJQgq6SAdyBRABGIAEEh4KDGRpc3BsYXlfbmFtZRgEIAEoCUIIukgFcgMYgAQSIgoQcm9vdF9zY29wZV9sYWJlbBgGIAEoCUIIukgFcgMYgAQSHAoHcm9sZV9pZBgHIAEoCUILukgI2AEBcgOwAQESJwoSb3duZXJfcHJpbmNpcGFsX2lkGAggASgJQgu6SAjYAQFyA7ABARIvChZjb19vd25lcl9wcmluY2lwYWxfaWRzGAkgAygJQg+6SAySAQkQICIFcgOwAQESKgoRYWxsb3dlZF9hdWRpZW5jZXMYCiADKAlCD7pIDJIBCRBAIgVyAxiABBInCg5hbGxvd2VkX3Njb3BlcxgLIAMoCUIPukgMkgEJEEAiBXIDGIABEhsKCXRhcmdldF9pZBgMIAEoCUIIukgFcgOwAQESHwoFc2NvcGUYDSABKAlCC7pICHIGEAIogIBASACIAQESNwoIYXBwcm92YWwYDiABKAsyJS5ob3N0Lmluc3RhbGxhdGlvbi52MS5Db25zZW50QXBwcm92YWxCCAoGX3Njb3BlSgQIAxAESgQIBRAGUhNzb2x1dGlvbl9pZGVudGlmaWVyUg9yb290X3Njb3BlX3BhdGgidAoZTmFycm93SW5zdGFsbGF0aW9uUmVxdWVzdBIYCgZvcmdfaWQYASABKAlCCLpIBXIDsAEBEiEKD2luc3RhbGxhdGlvbl9pZBgCIAEoCUIIukgFcgOwAQESGgoFc2NvcGUYAyABKAlCC7pICHIGEAIogIBAIlcKGFJldHJ5SW5zdGFsbGF0aW9uUmVxdWVzdBIYCgZvcmdfaWQYASABKAlCCLpIBXIDsAEBEiEKD2luc3RhbGxhdGlvbl9pZBgCIAEoCUIIukgFcgOwAQEiVwoYVW5pbnN0YWxsU29sdXRpb25SZXF1ZXN0EhgKBm9yZ19pZBgBIAEoCUIIukgFcgOwAQESIQoPaW5zdGFsbGF0aW9uX2lkGAIgASgJQgi6SAVyA7ABASK+AQokVHJhbnNmZXJJbnN0YWxsYXRpb25Pd25lcnNoaXBSZXF1ZXN0EhgKBm9yZ19pZBgBIAEoCUIIukgFcgOwAQESIQoPaW5zdGFsbGF0aW9uX2lkGAIgASgJQgi6SAVyA7ABARIoChZuZXdfb3duZXJfcHJpbmNpcGFsX2lkGAMgASgJQgi6SAVyA7ABARIvChZjb19vd25lcl9wcmluY2lwYWxfaWRzGAQgAygJQg+6SAySAQkQICIFcgOwAQEiVQoWR2V0SW5zdGFsbGF0aW9uUmVxdWVzdBIYCgZvcmdfaWQYASABKAlCCLpIBXIDsAEBEiEKD2luc3RhbGxhdGlvbl9pZBgCIAEoCUIIukgFcgOwAQEihQEKF0dldEluc3RhbGxhdGlvblJlc3BvbnNlEjQKDGluc3RhbGxhdGlvbhgBIAEoCzIeLnNhYXMuYWNjb3VudHMudjEuSW5zdGFsbGF0aW9uEjQKBmhlYWx0aBgCIAEoDjIkLnNhYXMuYWNjb3VudHMudjEuSW5zdGFsbGF0aW9uSGVhbHRoIp0BChhMaXN0SW5zdGFsbGF0aW9uc1JlcXVlc3QSGAoGb3JnX2lkGAEgASgJQgi6SAVyA7ABARI0CgZzdGF0dXMYAiABKA4yJC5zYWFzLmFjY291bnRzLnYxLkluc3RhbGxhdGlvblN0YXR1cxIdCglwYWdlX3NpemUYAyABKAVCCrpIBxoFGPQDKAASEgoKcGFnZV90b2tlbhgEIAEoCSKBAQoTSW5zdGFsbGF0aW9uU3VtbWFyeRI0CgxpbnN0YWxsYXRpb24YASABKAsyHi5zYWFzLmFjY291bnRzLnYxLkluc3RhbGxhdGlvbhI0CgZoZWFsdGgYAiABKA4yJC5zYWFzLmFjY291bnRzLnYxLkluc3RhbGxhdGlvbkhlYWx0aCJyChlMaXN0SW5zdGFsbGF0aW9uc1Jlc3BvbnNlEjwKDWluc3RhbGxhdGlvbnMYASADKAsyJS5zYWFzLmFjY291bnRzLnYxLkluc3RhbGxhdGlvblN1bW1hcnkSFwoPbmV4dF9wYWdlX3Rva2VuGAIgASgJIq0CChdSZXZpZXdlZFNvbHV0aW9uSW5zdGFsbBIOCgZvcmdfaWQYASABKAkSEAoIYWN0b3JfaWQYAiABKAkSEQoJdGFyZ2V0X2lkGAMgASgJEg4KBnRhcmdldBgEIAEoCRISCgpiaW5kaW5nX2lkGAUgASgJEg8KB3JlbGVhc2UYBiABKAkSFwoPZGVsaXZlcnlfZGlnZXN0GAcgASgJEjcKCGFwcHJvdmFsGAggASgLMiUuaG9zdC5pbnN0YWxsYXRpb24udjEuQ29uc2VudEFwcHJvdmFsEhkKEWluc3RhbGxhdGlvbl9yb2xlGAkgASgJEjsKB21vZHVsZXMYCiADKAsyKi5zYWFzLmFjY291bnRzLnYxLlJldmlld2VkU3VwcG9ydGluZ01vZHVsZSKiAgoYUmV2aWV3ZWRTdXBwb3J0aW5nTW9kdWxlEhEKCXByaW5jaXBhbBgBIAEoCRIRCgl0YXJnZXRfaWQYAiABKAkSDgoGdGFyZ2V0GAMgASgJEhIKCmJpbmRpbmdfaWQYBCABKAkSDwoHcmVsZWFzZRgFIAEoCRIXCg9kZWxpdmVyeV9kaWdlc3QYBiABKAkSGQoRaW5zdGFsbGF0aW9uX3JvbGUYByABKAkSHQoVcmV1c2VfaW5zdGFsbGF0aW9uX2lkGAggASgJEg0KBXNjb3BlGAkgASgJEjcKCGFwcHJvdmFsGAogASgLMiUuaG9zdC5pbnN0YWxsYXRpb24udjEuQ29uc2VudEFwcHJvdmFsEhAKCHJlcXVpcmVkGAsgASgIIlUKHFJldmlld1NvbHV0aW9uSW5zdGFsbFJlcXVlc3QSGAoGb3JnX2lkGAEgASgJQgi6SAVyA7ABARIbCgl0YXJnZXRfaWQYAiABKAlCCLpIBXIDsAEBIn0KHkluc3RhbGxSZXZpZXdlZFNvbHV0aW9uUmVxdWVzdBIYCgZvcmdfaWQYASABKAlCCLpIBXIDsAEBEkEKBnJldmlldxgCIAEoCzIpLnNhYXMuYWNjb3VudHMudjEuUmV2aWV3ZWRTb2x1dGlvbkluc3RhbGxCBrpIA8gBASJzCiRMaXN0T3JnYW5pemF0aW9uSW5zdGFsbGF0aW9uc1JlcXVlc3QSGAoGb3JnX2lkGAEgASgJQgi6SAVyA7ABARIdCglwYWdlX3NpemUYAiABKAVCCrpIBxoFGPQDKAASEgoKcGFnZV90b2tlbhgDIAEoCSLIAwoYT3JnYW5pemF0aW9uSW5zdGFsbGF0aW9uEjQKDGluc3RhbGxhdGlvbhgBIAEoCzIeLnNhYXMuYWNjb3VudHMudjEuSW5zdGFsbGF0aW9uEjQKBmhlYWx0aBgCIAEoDjIkLnNhYXMuYWNjb3VudHMudjEuSW5zdGFsbGF0aW9uSGVhbHRoEjMKBnRhcmdldBgDIAEoCzIjLnNhYXMuYWNjb3VudHMudjEuQXZhaWxhYmxlU29sdXRpb24SFwoPcm9vdF9zY29wZV9wYXRoGAQgASgJEjwKDWdyYW50ZWRfdGVhbXMYBSADKAsyJS5zYWFzLmFjY291bnRzLnYxLkNvbGxlY3Rpb25SZWFkR3JhbnQSPgoPaW5oZXJpdGVkX3RlYW1zGAYgAygLMiUuc2Fhcy5hY2NvdW50cy52MS5Db2xsZWN0aW9uUmVhZEdyYW50EjoKCXJlYWRpbmVzcxgHIAEoCzInLnNhYXMuYWNjb3VudHMudjEuSW5zdGFsbGF0aW9uUmVhZGluZXNzEjgKD3JlYWNoZWRfbW9kdWxlcxgIIAMoCzIfLnNhYXMuYWNjb3VudHMudjEuUmVhY2hlZE1vZHVsZSLtAQoSUmVhZGluZXNzQ29uZGl0aW9uEjYKBGtpbmQYASABKA4yKC5zYWFzLmFjY291bnRzLnYxLlJlYWRpbmVzc0NvbmRpdGlvbktpbmQSDwoHc3ViamVjdBgCIAEoCRIOCgZkZXRhaWwYAyABKAkSOAoKY2xlYXJlZF9ieRgEIAEoDjIkLnNhYXMuYWNjb3VudHMudjEuUmVhZGluZXNzQ2xlYXJlZEJ5EjEKBmFjdGlvbhgFIAEoDjIhLnNhYXMuYWNjb3VudHMudjEuUmVhZGluZXNzQWN0aW9uEhEKCXNvdXJjZV9pZBgGIAEoCSJbChVJbnN0YWxsYXRpb25SZWFkaW5lc3MSDQoFcmVhZHkYASABKAgSMwoFdW5tZXQYAiADKAsyJC5zYWFzLmFjY291bnRzLnYxLlJlYWRpbmVzc0NvbmRpdGlvbiJPCg1SZWFjaGVkTW9kdWxlEhAKCGF1ZGllbmNlGAEgASgJEhcKD2luc3RhbGxhdGlvbl9pZBgCIAEoCRITCgtyb3V0ZV9hbGlhcxgDIAEoCSKDAQolTGlzdE9yZ2FuaXphdGlvbkluc3RhbGxhdGlvbnNSZXNwb25zZRJBCg1pbnN0YWxsYXRpb25zGAEgAygLMiouc2Fhcy5hY2NvdW50cy52MS5Pcmdhbml6YXRpb25JbnN0YWxsYXRpb24SFwoPbmV4dF9wYWdlX3Rva2VuGAIgASgJIpMBCh5HcmFudEluc3RhbGxhdGlvblRvVGVhbVJlcXVlc3QSGAoGb3JnX2lkGAEgASgJQgi6SAVyA7ABARIhCg9pbnN0YWxsYXRpb25faWQYAiABKAlCCLpIBXIDsAEBEhkKB3RlYW1faWQYAyABKAlCCLpIBXIDsAEBEhkKB3JvbGVfaWQYBCABKAlCCLpIBXIDsAEBInUKE0NhcnJpZWRNb2R1bGVBY2Nlc3MSEAoIYXVkaWVuY2UYASABKAkSFwoPaW5zdGFsbGF0aW9uX2lkGAIgASgJEhIKCnNjb3BlX3BhdGgYAyABKAkSDwoHcm9sZV9pZBgEIAEoCRIOCgZzY29wZXMYBSADKAkiuwEKH0dyYW50SW5zdGFsbGF0aW9uVG9UZWFtUmVzcG9uc2USKwoFZ3JhbnQYASABKAsyHC5zYWFzLmFjY291bnRzLnYxLlNjb3BlR3JhbnQSNgoHY2FycmllZBgCIAMoCzIlLnNhYXMuYWNjb3VudHMudjEuQ2FycmllZE1vZHVsZUFjY2VzcxIzCgV1bm1ldBgDIAMoCzIkLnNhYXMuYWNjb3VudHMudjEuUmVhZGluZXNzQ29uZGl0aW9uIpYBCiFSZXZva2VJbnN0YWxsYXRpb25Gcm9tVGVhbVJlcXVlc3QSGAoGb3JnX2lkGAEgASgJQgi6SAVyA7ABARIhCg9pbnN0YWxsYXRpb25faWQYAiABKAlCCLpIBXIDsAEBEhkKB3RlYW1faWQYAyABKAlCCLpIBXIDsAEBEhkKB3JvbGVfaWQYBCABKAlCCLpIBXIDsAEBIoQCChFBdmFpbGFibGVTb2x1dGlvbhIbCgl0YXJnZXRfaWQYASABKAlCCLpIBXIDsAEBEhIKCmJpbmRpbmdfaWQYAiABKAkSEwoLcm91dGVfYWxpYXMYAyABKAkSGQoRcmVsZWFzZV9wdWJsaXNoZXIYBCABKAkSFAoMcmVsZWFzZV9uYW1lGAUgASgJEhcKD3JlbGVhc2VfdmVyc2lvbhgGIAEoCRIZChFvcGVuZWRfZ2VuZXJhdGlvbhgHIAEoBBIaChJhcHBsaWVkX2dlbmVyYXRpb24YCCABKAQSEQoJaW5zdGFsbGVkGAkgASgIEhUKBGtpbmQYCiABKAlCB7pIBHICGCAibAodTGlzdEF2YWlsYWJsZVNvbHV0aW9uc1JlcXVlc3QSGAoGb3JnX2lkGAEgASgJQgi6SAVyA7ABARIdCglwYWdlX3NpemUYAiABKAVCCrpIBxoFGPQDKAASEgoKcGFnZV90b2tlbhgDIAEoCSJxCh5MaXN0QXZhaWxhYmxlU29sdXRpb25zUmVzcG9uc2USNgoJc29sdXRpb25zGAEgAygLMiMuc2Fhcy5hY2NvdW50cy52MS5BdmFpbGFibGVTb2x1dGlvbhIXCg9uZXh0X3BhZ2VfdG9rZW4YAiABKAkquwEKEkluc3RhbGxhdGlvblN0YXR1cxIjCh9JTlNUQUxMQVRJT05fU1RBVFVTX1VOU1BFQ0lGSUVEEAASHgoaSU5TVEFMTEFUSU9OX1NUQVRVU19BQ1RJVkUQARIfChtJTlNUQUxMQVRJT05fU1RBVFVTX1JFVk9LRUQQAhIfChtJTlNUQUxMQVRJT05fU1RBVFVTX1BFTkRJTkcQAxIeChpJTlNUQUxMQVRJT05fU1RBVFVTX0ZBSUxFRBAEKoQCChJJbnN0YWxsYXRpb25IZWFsdGgSIwofSU5TVEFMTEFUSU9OX0hFQUxUSF9VTlNQRUNJRklFRBAAEh8KG0lOU1RBTExBVElPTl9IRUFMVEhfSEVBTFRIWRABEikKJUlOU1RBTExBVElPTl9IRUFMVEhfTk9fRUxJR0lCTEVfT1dORVIQAhIlCiFJTlNUQUxMQVRJT05fSEVBTFRIX0FHRU5UX1JFVk9LRUQQAxImCiJJTlNUQUxMQVRJT05fSEVBTFRIX0FHRU5UX0RJU0FCTEVEEAQSLgoqSU5TVEFMTEFUSU9OX0hFQUxUSF9TVEFORElOR19HUkFOVF9NSVNTSU5HEAUq8wMKFlJlYWRpbmVzc0NvbmRpdGlvbktpbmQSKAokUkVBRElORVNTX0NPTkRJVElPTl9LSU5EX1VOU1BFQ0lGSUVEEAASMQotUkVBRElORVNTX0NPTkRJVElPTl9LSU5EX0lOU1RBTExfU1RFUF9QRU5ESU5HEAESMAosUkVBRElORVNTX0NPTkRJVElPTl9LSU5EX0lOU1RBTExfU1RFUF9GQUlMRUQQAhItCilSRUFESU5FU1NfQ09ORElUSU9OX0tJTkRfUkVDT1JEX1VOSEVBTFRIWRADEjUKMVJFQURJTkVTU19DT05ESVRJT05fS0lORF9NT0RVTEVfUFJJTkNJUEFMX09NSVRURUQQBBI2CjJSRUFESU5FU1NfQ09ORElUSU9OX0tJTkRfT1BFUkFUSU9OX0JJTkRJTkdfT01JVFRFRBAFEjEKLVJFQURJTkVTU19DT05ESVRJT05fS0lORF9TT1VSQ0VfTk9UX0RFTEVHQVRFRBAGEjkKNVJFQURJTkVTU19DT05ESVRJT05fS0lORF9SRUFDSEVEX01PRFVMRV9OT1RfSU5TVEFMTEVEEAcSPgo6UkVBRElORVNTX0NPTkRJVElPTl9LSU5EX0NBUlJJRURfTU9EVUxFX0FDQ0VTU19OT1RfREVSSVZFRBAIKtUBChJSZWFkaW5lc3NDbGVhcmVkQnkSJAogUkVBRElORVNTX0NMRUFSRURfQllfVU5TUEVDSUZJRUQQABIfChtSRUFESU5FU1NfQ0xFQVJFRF9CWV9WSUVXRVIQARIrCidSRUFESU5FU1NfQ0xFQVJFRF9CWV9PUkdBTklaQVRJT05fQURNSU4QAhIqCiZSRUFESU5FU1NfQ0xFQVJFRF9CWV9QTEFURk9STV9PUEVSQVRPUhADEh8KG1JFQURJTkVTU19DTEVBUkVEX0JZX05PQk9EWRAEKp8CCg9SZWFkaW5lc3NBY3Rpb24SIAocUkVBRElORVNTX0FDVElPTl9VTlNQRUNJRklFRBAAEikKJVJFQURJTkVTU19BQ1RJT05fTk9ORV9BVF9USElTX1NVUkZBQ0UQARInCiNSRUFESU5FU1NfQUNUSU9OX1JFVFJZX0lOU1RBTExfU1RFUBACEiUKIVJFQURJTkVTU19BQ1RJT05fUkVDT05ORUNUX1NPVVJDRRADEicKI1JFQURJTkVTU19BQ1RJT05fVFJBTlNGRVJfT1dORVJTSElQEAQSKwonUkVBRElORVNTX0FDVElPTl9JTlNUQUxMX1JFQUNIRURfTU9EVUxFEAUSGQoVUkVBRElORVNTX0FDVElPTl9XQUlUEAYyrhUKE0luc3RhbGxhdGlvblNlcnZpY2USxgEKFVJldmlld1NvbHV0aW9uSW5zdGFsbBIuLnNhYXMuYWNjb3VudHMudjEuUmV2aWV3U29sdXRpb25JbnN0YWxsUmVxdWVzdBopLnNhYXMuYWNjb3VudHMudjEuUmV2aWV3ZWRTb2x1dGlvbkluc3RhbGwiUsLzGCIIAhAEKgwKBm9yZ19pZBACGAEwAToCEAFAAUgDUANYA2ABgtPkkwImOgEqIiEvdjEvc29sdXRpb24taW5zdGFsbGF0aW9ucy9yZXZpZXcSuAEKF0luc3RhbGxSZXZpZXdlZFNvbHV0aW9uEjAuc2Fhcy5hY2NvdW50cy52MS5JbnN0YWxsUmV2aWV3ZWRTb2x1dGlvblJlcXVlc3QaHi5zYWFzLmFjY291bnRzLnYxLkluc3RhbGxhdGlvbiJLwvMYIggCEAQqDAoGb3JnX2lkEAIYATABOgIQAUABSARQA1gDYAGC0+STAh86ASoiGi92MS9zb2x1dGlvbi1pbnN0YWxsYXRpb25zEroBCg9JbnN0YWxsU29sdXRpb24SKC5zYWFzLmFjY291bnRzLnYxLkluc3RhbGxTb2x1dGlvblJlcXVlc3QaHi5zYWFzLmFjY291bnRzLnYxLkluc3RhbGxhdGlvbiJdwvMYPQgCEAQqDAoGb3JnX2lkEAIYATABOh0KGXNhYXMuaW5zdGFsbGF0aW9uLmNyZWF0ZWQQAkABSARQA1gDYAGC0+STAhY6ASoiES92MS9pbnN0YWxsYXRpb25zEtIBChFVbmluc3RhbGxTb2x1dGlvbhIqLnNhYXMuYWNjb3VudHMudjEuVW5pbnN0YWxsU29sdXRpb25SZXF1ZXN0GhYuZ29vZ2xlLnByb3RvYnVmLkVtcHR5InnC8xg9CAIQBCoMCgZvcmdfaWQQAhgBMAE6HQoZc2Fhcy5pbnN0YWxsYXRpb24ucmV2b2tlZBACQAFIBFADWANgAYLT5JMCMjoBKiItL3YxL2luc3RhbGxhdGlvbnMve2luc3RhbGxhdGlvbl9pZH06dW5pbnN0YWxsEtIBChJOYXJyb3dJbnN0YWxsYXRpb24SKy5zYWFzLmFjY291bnRzLnYxLk5hcnJvd0luc3RhbGxhdGlvblJlcXVlc3QaFi5nb29nbGUucHJvdG9idWYuRW1wdHkid8LzGD4IAhAEKgwKBm9yZ19pZBACGAEwAToeChpzYWFzLmluc3RhbGxhdGlvbi5uYXJyb3dlZBACQAFIBFADWANgAYLT5JMCLzoBKiIqL3YxL2luc3RhbGxhdGlvbnMve2luc3RhbGxhdGlvbl9pZH06bmFycm93EtYBChFSZXRyeUluc3RhbGxhdGlvbhIqLnNhYXMuYWNjb3VudHMudjEuUmV0cnlJbnN0YWxsYXRpb25SZXF1ZXN0Gh4uc2Fhcy5hY2NvdW50cy52MS5JbnN0YWxsYXRpb24idcLzGD0IAhAEKgwKBm9yZ19pZBACGAEwATodChlzYWFzLmluc3RhbGxhdGlvbi5yZXRyaWVkEAJAAUgEUANYA2ABgtPkkwIuOgEqIikvdjEvaW5zdGFsbGF0aW9ucy97aW5zdGFsbGF0aW9uX2lkfTpyZXRyeRKJAgodVHJhbnNmZXJJbnN0YWxsYXRpb25Pd25lcnNoaXASNi5zYWFzLmFjY291bnRzLnYxLlRyYW5zZmVySW5zdGFsbGF0aW9uT3duZXJzaGlwUmVxdWVzdBoeLnNhYXMuYWNjb3VudHMudjEuSW5zdGFsbGF0aW9uIo8BwvMYSwgCEAQqDAoGb3JnX2lkEAIYATABOisKJ3NhYXMuaW5zdGFsbGF0aW9uLm93bmVyc2hpcF90cmFuc2ZlcnJlZBACQAFIBFADWANgAYLT5JMCOjoBKiI1L3YxL2luc3RhbGxhdGlvbnMve2luc3RhbGxhdGlvbl9pZH06dHJhbnNmZXJPd25lcnNoaXASuQEKD0dldEluc3RhbGxhdGlvbhIoLnNhYXMuYWNjb3VudHMudjEuR2V0SW5zdGFsbGF0aW9uUmVxdWVzdBopLnNhYXMuYWNjb3VudHMudjEuR2V0SW5zdGFsbGF0aW9uUmVzcG9uc2UiUcLzGCIIAhADKgwKBm9yZ19pZBACGAEwAToCEAFAAUgDUANYA2ABgtPkkwIlEiMvdjEvaW5zdGFsbGF0aW9ucy97aW5zdGFsbGF0aW9uX2lkfRKGAQoRTGlzdEluc3RhbGxhdGlvbnMSKi5zYWFzLmFjY291bnRzLnYxLkxpc3RJbnN0YWxsYXRpb25zUmVxdWVzdBorLnNhYXMuYWNjb3VudHMudjEuTGlzdEluc3RhbGxhdGlvbnNSZXNwb25zZSIYwvMYFAgDEAEwAToCEAFAAUgHUANYA2ABEsoBChZMaXN0QXZhaWxhYmxlU29sdXRpb25zEi8uc2Fhcy5hY2NvdW50cy52MS5MaXN0QXZhaWxhYmxlU29sdXRpb25zUmVxdWVzdBowLnNhYXMuYWNjb3VudHMudjEuTGlzdEF2YWlsYWJsZVNvbHV0aW9uc1Jlc3BvbnNlIk3C8xgiCAIQBCoMCgZvcmdfaWQQAhgBMAE6AhABQAFIA1ADWANgAYLT5JMCIRIfL3YxL2luc3RhbGxhdGlvbnM6bGlzdEF2YWlsYWJsZRLRAQodTGlzdE9yZ2FuaXphdGlvbkluc3RhbGxhdGlvbnMSNi5zYWFzLmFjY291bnRzLnYxLkxpc3RPcmdhbml6YXRpb25JbnN0YWxsYXRpb25zUmVxdWVzdBo3LnNhYXMuYWNjb3VudHMudjEuTGlzdE9yZ2FuaXphdGlvbkluc3RhbGxhdGlvbnNSZXNwb25zZSI/wvMYIggCEAQqDAoGb3JnX2lkEAIYATABOgIQAUABSANQA1gDYAGC0+STAhMSES92MS9pbnN0YWxsYXRpb25zEvABChdHcmFudEluc3RhbGxhdGlvblRvVGVhbRIwLnNhYXMuYWNjb3VudHMudjEuR3JhbnRJbnN0YWxsYXRpb25Ub1RlYW1SZXF1ZXN0GjEuc2Fhcy5hY2NvdW50cy52MS5HcmFudEluc3RhbGxhdGlvblRvVGVhbVJlc3BvbnNlInDC8xg4CAIQBCoMCgZvcmdfaWQQAhgBMAE6FgoSc2Fhcy5zY29wZS5ncmFudGVkEAJAAUgEUANYA2ABeAKC0+STAi46ASoiKS92MS9pbnN0YWxsYXRpb25zL3tpbnN0YWxsYXRpb25faWR9L3RlYW1zEu0BChpSZXZva2VJbnN0YWxsYXRpb25Gcm9tVGVhbRIzLnNhYXMuYWNjb3VudHMudjEuUmV2b2tlSW5zdGFsbGF0aW9uRnJvbVRlYW1SZXF1ZXN0GhYuZ29vZ2xlLnByb3RvYnVmLkVtcHR5IoEBwvMYOAgCEAQqDAoGb3JnX2lkEAIYATABOhYKEnNhYXMuc2NvcGUucmV2b2tlZBACQAFIBFADWANgAXgCgtPkkwI/OgEqIjovdjEvaW5zdGFsbGF0aW9ucy97aW5zdGFsbGF0aW9uX2lkfS90ZWFtcy97dGVhbV9pZH06cmV2b2tlYgZwcm90bzM", [file_buf_validate_validate, file_google_api_annotations, file_google_protobuf_empty, file_host_installation_v1_installation, file_google_protobuf_timestamp, file_saas_accounts_v1_authorization, file_saas_policy_v1_options]); /** * Installation is one solution's bound identity in an org. Server-assigned ids; @@ -177,7 +177,10 @@ export type InstallSolutionRequest = Message<"saas.accounts.v1.InstallSolutionRe /** * The least-privilege role granted to the agent at the root node. This is * deliberately NOT the full ":catalog" role; widening is a separate, - * audited admin action. + * audited admin action. Empty when the delivered module declares its own + * installation role: the host selects a named one, and "none" grants the + * agent no standing role at all. A role given for "none" is refused, and an + * empty one is refused for a target that declares no installation role. * * @generated from field: string role_id = 7; */ diff --git a/module/services/frontend/code/packages/saas-sdk/package.json b/module/services/frontend/code/packages/saas-sdk/package.json index 15472f8a5..b7791c0a9 100644 --- a/module/services/frontend/code/packages/saas-sdk/package.json +++ b/module/services/frontend/code/packages/saas-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@codefly-dev/saas-sdk", - "version": "0.3.41", + "version": "0.3.42", "description": "Typed Connect client, gateway-bound facades, and audit data-graph tooling for the saas-starter public API.", "repository": { "type": "git", diff --git a/module/services/frontend/code/src/features/solution-installations/ui/declared-access.tsx b/module/services/frontend/code/src/features/solution-installations/ui/declared-access.tsx new file mode 100644 index 000000000..7c470efde --- /dev/null +++ b/module/services/frontend/code/src/features/solution-installations/ui/declared-access.tsx @@ -0,0 +1,71 @@ +import type { ReviewedOffer } from "@/gen/host/installation/v1/installation_pb"; +import { offerScopeText } from "../model/install-review"; + +/** + * The machine access an installation receives, as the authority's offer + * declares it: its signed installation role, and the most each declared call + * may ask. One rendering for a solution's review and a module page's, so the + * two reviews cannot describe the same declaration differently. + */ +export function DeclaredAccess({ + subject, + installationRole, + offer, +}: { + subject: "solution" | "module"; + installationRole: string; + offer: Pick; +}) { + const machineRole = offer.roles.find( + (role) => role.name === installationRole, + ); + const heading = subject === "solution" ? "Solution access" : "Module access"; + return ( +
+

{heading}

+ {installationRole === "none" ? ( +

+ This {subject} receives no standing machine access. + {subject === "solution" && + " Setup receives only temporary permission to complete this installation."} +

+ ) : !installationRole ? ( +

+ This {subject} declares no installation access. The host refuses to + install it until its owner declares one. +

+ ) : ( + <> +

+ The {subject} declares its installation access as “ + {machineRole?.name ?? installationRole}”. +

+
    + {machineRole?.scopes.map(offerScopeText).map((text) => ( +
  • {text}
  • + ))} +
+ {machineRole?.scopes.length === 0 && ( +

This declared role grants no data access.

+ )} + + )} + {offer.ceiling.length > 0 && ( + <> +

+ Declared operations, still subject to the caller’s assigned access: +

+
    + {offer.ceiling.map((binding) => ( +
  • + {binding.module}:{" "} + {binding.scopes.map(offerScopeText).join("; ") || + "no data access"} +
  • + ))} +
+ + )} +
+ ); +} diff --git a/module/services/frontend/code/src/features/solution-installations/ui/install-module-dialog.tsx b/module/services/frontend/code/src/features/solution-installations/ui/install-module-dialog.tsx index 787e06bf5..f656c92bb 100644 --- a/module/services/frontend/code/src/features/solution-installations/ui/install-module-dialog.tsx +++ b/module/services/frontend/code/src/features/solution-installations/ui/install-module-dialog.tsx @@ -100,7 +100,9 @@ export function InstallModuleDialog({ owner of record. -
+ {/* min-w-0: the dialog is a grid, and a long role label must not + widen its track past the dialog and push Install off-screen. */} + - - {!withheld && field.value - ? roleLabel.get(field.value) || "Role unavailable" - : undefined} + + {!withheld && field.value ? ( + + {roleLabel.get(field.value) || "Role unavailable"} + + ) : undefined} diff --git a/module/services/frontend/code/src/features/solution-installations/ui/install-module-page-dialog.tsx b/module/services/frontend/code/src/features/solution-installations/ui/install-module-page-dialog.tsx new file mode 100644 index 000000000..f7be9e3a4 --- /dev/null +++ b/module/services/frontend/code/src/features/solution-installations/ui/install-module-page-dialog.tsx @@ -0,0 +1,134 @@ +"use client"; + +import { createClient } from "@connectrpc/connect"; +import { useQuery } from "@tanstack/react-query"; +import { useState } from "react"; +import { OfferService } from "@/gen/host/installation/v1/installation_pb"; +import type { AvailableSolution } from "@/gen/saas/accounts/v1/installations_pb"; +import { apiTransport } from "@/lib/connect/transport"; +import { + Button, + DelayedLoading, + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/shared/ui"; +import { offerScopeText } from "../model/install-review"; +import { hostRefusal, releaseText } from "../model/installations"; +import { DeclaredAccess } from "./declared-access"; + +const offers = createClient(OfferService, apiTransport); + +/** + * The review of a module page that declares roles: what it receives and what + * each of its roles permits, read from the authority's offer as a solution's + * review is. A module stays installed by this host (the authority installs + * only solutions), so Install carries no agent role: the module's signed + * installation role decides. Teams take its roles in Set up, as a solution's do. + */ +export function InstallModulePageDialog({ + orgId, + solution, + onSubmit, + isPending, + error, + onClose, +}: { + orgId: string; + solution: AvailableSolution; + onSubmit: () => void; + isPending: boolean; + error?: string; + onClose: () => void; +}) { + const [approved, setApproved] = useState(false); + const review = useQuery({ + queryKey: ["module-page-install-review", orgId, solution.targetId], + queryFn: () => + offers.reviewOffer({ + organizationId: orgId, + target: solution.routeAlias, + }), + staleTime: Infinity, + retry: false, + refetchOnWindowFocus: false, + refetchOnReconnect: false, + }); + const offer = !review.isError && !review.isFetching ? review.data : undefined; + return ( + !open && onClose()}> + + + Install {solution.routeAlias} + + {releaseText(solution)}. Review the module page and its roles. + + + + {review.isError &&

{hostRefusal(review.error)}

} + {offer && ( +
+ +
+

Roles

+
    + {offer.roles.map((role) => ( +
  • + {role.name}:{" "} + {role.scopes.map(offerScopeText).join("; ") || + "No data access declared."} +
  • + ))} +
+

+ Choose a team for each role in Set up once the module is + installed. Nobody holds a role until it is granted there. +

+
+ +
+ )} + {error &&

The host refused the install: {error}

} + + + + + +
+
+ ); +} diff --git a/module/services/frontend/code/src/features/solution-installations/ui/install-solution-dialog.tsx b/module/services/frontend/code/src/features/solution-installations/ui/install-solution-dialog.tsx index 46a77c58c..494501546 100644 --- a/module/services/frontend/code/src/features/solution-installations/ui/install-solution-dialog.tsx +++ b/module/services/frontend/code/src/features/solution-installations/ui/install-solution-dialog.tsx @@ -24,13 +24,13 @@ import { import { assertMatchingOffer, moduleConsentText, - offerScopeText, } from "../model/install-review"; import { hostRefusal, releaseText } from "../model/installations"; import { choicesMatch, useInstallationTeamChoices, } from "../service/team-choices"; +import { DeclaredAccess } from "./declared-access"; import { InstallationTeamChoices } from "./installation-team-choices"; const offers = createClient(OfferService, apiTransport); @@ -81,9 +81,6 @@ export function InstallSolutionDialog({ refetchOnReconnect: false, }); const data = !review.isError && !review.isFetching ? review.data : undefined; - const machineRole = data?.offer.roles.find( - (role) => role.name === data.consent.installationRole, - ); return ( !open && onClose()}> @@ -101,48 +98,11 @@ export function InstallSolutionDialog({ {review.isError &&

{hostRefusal(review.error)}

} {data && (
-
-

Solution access

- {data.consent.installationRole === "none" ? ( -

- This solution receives no standing machine access. Setup - receives only temporary permission to complete this - installation. -

- ) : ( - <> -

- The solution declares its installation access as “ - {machineRole?.name}”. -

-
    - {machineRole?.scopes.map((scope, index) => ( -
  • {offerScopeText(scope)}
  • - ))} -
- {machineRole?.scopes.length === 0 && ( -

This declared role grants no data access.

- )} - - )} - {data.offer.ceiling.length > 0 && ( - <> -

- Declared operations, still subject to the caller’s assigned - access: -

-
    - {data.offer.ceiling.map((binding) => ( -
  • - {binding.module}:{" "} - {binding.scopes.map(offerScopeText).join("; ") || - "no data access"} -
  • - ))} -
- - )} -
+

Supporting modules

{data.modules.length === 0 && ( @@ -171,8 +131,8 @@ export function InstallSolutionDialog({

No standing machine access is granted.

)}
    - {access.map((line, index) => ( -
  • {line}
  • + {access.map((line) => ( +
  • {line}
  • ))}
diff --git a/module/services/frontend/code/src/features/solution-installations/ui/installation-setup-page.container.test.tsx b/module/services/frontend/code/src/features/solution-installations/ui/installation-setup-page.container.test.tsx index ce52967e8..c2160077e 100644 --- a/module/services/frontend/code/src/features/solution-installations/ui/installation-setup-page.container.test.tsx +++ b/module/services/frontend/code/src/features/solution-installations/ui/installation-setup-page.container.test.tsx @@ -86,9 +86,20 @@ const docsInstallation = { }; it("uses shared solution roles without offering a legacy host grant", async () => { - const grants = serve({ installations: [installation({ rootScopePath: "", readiness: { ready: true, unmet: [] } })] }); + const grants = serve({ + installations: [ + installation({ + rootScopePath: "", + readiness: { ready: true, unmet: [] }, + }), + ], + }); render(); - expect(await screen.findByText(/Assign people and teams using the solution roles above/)).toBeTruthy(); + expect( + await screen.findByText( + /Assign people and teams using the solution roles above/, + ), + ).toBeTruthy(); expect(screen.queryByText("Team access on this host")).toBeNull(); expect(screen.queryByRole("button", { name: "Grant a team" })).toBeNull(); expect(grants).toHaveLength(0); @@ -119,8 +130,13 @@ function serve(installationsBody: JsonBodyType) { ), http.post(rpc("TeamService", "ListTeams"), () => HttpResponse.json(teams)), // The shared Setup UI's read; a test about it answers it itself. - http.post("http://localhost:3000/host.installation.v1.OfferService/ReviewOffer", () => - HttpResponse.json({ code: "not_found", message: '"suite" is not delivered here' }, { status: 404 }), + http.post( + "http://localhost:3000/host.installation.v1.OfferService/ReviewOffer", + () => + HttpResponse.json( + { code: "not_found", message: '"suite" is not delivered here' }, + { status: 404 }, + ), ), http.post( rpc("InstallationService", "GrantInstallationToTeam"), @@ -317,45 +333,78 @@ it("grants once, and reports what that grant carried", async () => { // authority's answer through the host installation interface, at the // organization the session is at, beside this host's own team access. it("shows the solution's roles, what they grant and who holds them, from the authority", async () => { - serve({ installations: [installation({ readiness: { ready: true, unmet: [] } })] }); + serve({ + installations: [installation({ readiness: { ready: true, unmet: [] } })], + }); const asked: { procedure: string; body: unknown }[] = []; const host = (procedure: string, answer: JsonBodyType) => - http.post(`http://localhost:3000/host.installation.v1.${procedure}`, async ({ request }) => { - asked.push({ procedure, body: await request.json() }); - return HttpResponse.json(answer); - }); + http.post( + `http://localhost:3000/host.installation.v1.${procedure}`, + async ({ request }) => { + asked.push({ procedure, body: await request.json() }); + return HttpResponse.json(answer); + }, + ); server.use( host("OfferService/ReviewOffer", { target: "suite", approval: { targetId: "suite", generation: "3", digest: "sha256:suite" }, - installation: { id: "core-suite", organizationId: "org-1", target: "suite", status: "STATUS_ACTIVE", revision: "2", role: "viewer" }, - ceiling: [{ binding: "documents", module: "documents", scopes: [{ resourceKind: "example-records", actions: ["read"] }] }], + installation: { + id: "core-suite", + organizationId: "org-1", + target: "suite", + status: "STATUS_ACTIVE", + revision: "2", + role: "viewer", + }, + ceiling: [ + { + binding: "documents", + module: "documents", + scopes: [{ resourceKind: "example-records", actions: ["read"] }], + }, + ], roles: [ { name: "viewer", scopes: [{ resourceKind: "suite.page", actions: ["read"] }], - capabilities: [{ module: "suite", operation: "GET /pages/{id}", requires: [{ resourceKind: "suite.page", actions: ["read"] }] }], + capabilities: [ + { + module: "suite", + operation: "GET /pages/{id}", + requires: [{ resourceKind: "suite.page", actions: ["read"] }], + }, + ], }, ], }), - host("InstallationService/ListRoleGrants", { grants: [{ role: "viewer", teamId: SUPPORT_TEAM }] }), + host("InstallationService/ListRoleGrants", { + grants: [{ role: "viewer", teamId: SUPPORT_TEAM }], + }), ); render(); - expect(await screen.findByRole("heading", { name: "Role: viewer" })).toBeTruthy(); + expect( + await screen.findByRole("heading", { name: "Role: viewer" }), + ).toBeTruthy(); expect(await screen.findByText("GET /pages/{id}")).toBeTruthy(); expect(await screen.findByText("viewer · Team Support")).toBeTruthy(); expect(screen.getByText("Team access on this host")).toBeTruthy(); - expect(asked[0]).toEqual({ procedure: "OfferService/ReviewOffer", body: { organizationId: "org-1", target: "suite" } }); + expect(asked[0]).toEqual({ + procedure: "OfferService/ReviewOffer", + body: { organizationId: "org-1", target: "suite" }, + }); expect(asked[1]).toEqual({ procedure: "InstallationService/ListRoleGrants", body: { organizationId: "org-1", installationId: "core-suite" }, }); }); -// A module page that declares roles is set up as a solution is: the authority -// reviews its offer, and a team is offered for each role it declares. -it("offers a module page that declares roles a team per role, as a solution", async () => { +// A module page that declares roles is set up through its roles, as a +// solution is. Its offer carries no consent digest (the authority installs no +// module), so it has no staged team picker: Setup's role grant form, the +// authority's GrantRole, is how each role takes a team. +it("sets up a module page that declares roles through Setup's role grant form", async () => { serve({ installations: [ installation({ @@ -365,26 +414,44 @@ it("offers a module page that declares roles a team per role, as a solution", as ], }); server.use( - http.post("http://localhost:3000/host.installation.v1.OfferService/ReviewOffer", () => - HttpResponse.json({ - target: "suite", - approval: { targetId: "suite", generation: "3", digest: "sha256:suite" }, - installation: { id: "core-suite", organizationId: "org-1", target: "suite", status: "STATUS_ACTIVE", revision: "2" }, - roles: [ - { name: "installer", scopes: [{ resourceKind: "suite.page", actions: ["read", "write"] }] }, - { name: "viewer", scopes: [{ resourceKind: "suite.page", actions: ["read"] }] }, - ], - }), + http.post( + "http://localhost:3000/host.installation.v1.OfferService/ReviewOffer", + () => + HttpResponse.json({ + target: "suite", + installationRole: "none", + installation: { + id: "core-suite", + organizationId: "org-1", + target: "suite", + status: "STATUS_ACTIVE", + revision: "2", + }, + roles: [ + { + name: "installer", + scopes: [ + { resourceKind: "suite.page", actions: ["read", "write"] }, + ], + }, + { + name: "viewer", + scopes: [{ resourceKind: "suite.page", actions: ["read"] }], + }, + ], + }), ), - http.post("http://localhost:3000/host.installation.v1.InstallationService/ListRoleGrants", () => - HttpResponse.json({ grants: [] }), + http.post( + "http://localhost:3000/host.installation.v1.InstallationService/ListRoleGrants", + () => HttpResponse.json({ grants: [] }), ), ); render(); - const access = await screen.findByRole("region", { name: "Team access" }); - expect(within(access).getByText(/For each role this solution declares/)).toBeTruthy(); - expect(within(access).getByText(/viewer:/)).toBeTruthy(); + expect( + await screen.findByRole("button", { name: "Grant role" }), + ).toBeTruthy(); + expect(screen.queryByRole("region", { name: "Team access" })).toBeNull(); // Its roles are the one grant form: no host team access card beside them. expect(screen.queryByText("Team access on this host")).toBeNull(); expect(screen.queryByRole("button", { name: "Grant a team" })).toBeNull(); diff --git a/module/services/frontend/code/src/features/solution-installations/ui/installation-setup-page.tsx b/module/services/frontend/code/src/features/solution-installations/ui/installation-setup-page.tsx index 9289e8515..0e346d978 100644 --- a/module/services/frontend/code/src/features/solution-installations/ui/installation-setup-page.tsx +++ b/module/services/frontend/code/src/features/solution-installations/ui/installation-setup-page.tsx @@ -201,10 +201,13 @@ function InstallationSetup({ diff --git a/module/services/frontend/code/src/features/solution-installations/ui/solution-installations-page.container.test.tsx b/module/services/frontend/code/src/features/solution-installations/ui/solution-installations-page.container.test.tsx index d1a34a254..57799688f 100644 --- a/module/services/frontend/code/src/features/solution-installations/ui/solution-installations-page.container.test.tsx +++ b/module/services/frontend/code/src/features/solution-installations/ui/solution-installations-page.container.test.tsx @@ -28,7 +28,8 @@ const session = vi.hoisted(() => ({ platformRole: undefined as PlatformRole | undefined, switchOrganization: async () => {}, })); -vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn() }) })); +const push = vi.hoisted(() => vi.fn()); +vi.mock("next/navigation", () => ({ useRouter: () => ({ push }) })); vi.mock("@/lib/auth", () => ({ useAuth: () => session })); vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } })); @@ -55,6 +56,8 @@ afterEach(() => { expect(actual).toEqual(expected); }); +const REVIEW_OFFER = + "http://localhost:3000/host.installation.v1.OfferService/ReviewOffer"; const EXPENSES_TARGET = "aaaaaaaa-0000-4000-8000-000000000001"; const LEDGER_TARGET = "aaaaaaaa-0000-4000-8000-000000000002"; // The consent the page sends: the reviewed target and generation, a zero @@ -221,6 +224,17 @@ function serve({ reads.teams++; return HttpResponse.json(teamsBody); }), + // A module that declares no roles: the authority refuses its offer. + http.post(REVIEW_OFFER, () => + HttpResponse.json( + { + code: "not_found", + message: + "not a composed solution or a module page declaring roles; a module is installed by composition", + }, + { status: 404 }, + ), + ), ); return reads; } @@ -1554,6 +1568,176 @@ describe("accessible validation and host text", () => { }); }); +describe("installing a module: the form follows its declared roles, not its kind", () => { + it("opens the agent-role form for a module whose offer the authority refuses", async () => { + let asked: unknown; + serve(); + server.use( + http.post(REVIEW_OFFER, async ({ request }) => { + asked = await request.json(); + return HttpResponse.json( + { code: "not_found", message: "declares no roles" }, + { status: 404 }, + ); + }), + ); + renderInApp(); + fireEvent.click( + await screen.findByRole("button", { name: "Install ledger" }), + ); + const dialog = await screen.findByRole("dialog"); + expect( + within(dialog).getByRole("combobox", { name: "Agent role" }), + ).toBeTruthy(); + expect(within(dialog).queryByText("Module access")).toBeNull(); + expect(asked).toEqual({ organizationId: "org-1", target: "ledger" }); + }); + + it("shows a failed read of a module's roles instead of guessing its form, and reads again", async () => { + let reads = 0; + serve(); + server.use( + http.post(REVIEW_OFFER, () => { + reads++; + return reads === 1 + ? HttpResponse.json( + { code: "unavailable", message: "authority restarting" }, + { status: 503 }, + ) + : HttpResponse.json( + { code: "not_found", message: "declares no roles" }, + { status: 404 }, + ); + }), + ); + renderInApp(); + fireEvent.click( + await screen.findByRole("button", { name: "Install ledger" }), + ); + expect(await screen.findByText(/authority restarting/)).toBeTruthy(); + expect(screen.queryByRole("dialog")).toBeNull(); + fireEvent.click(screen.getByRole("button", { name: "Try again" })); + const dialog = await screen.findByRole("dialog"); + expect( + within(dialog).getByRole("combobox", { name: "Agent role" }), + ).toBeTruthy(); + expect(reads).toBe(2); + }); + + it("reviews a module page that declares roles, installs it with no agent role, and opens its Set up", async () => { + let installed: unknown; + serve(); + server.use( + http.post(REVIEW_OFFER, () => + HttpResponse.json({ + target: "ledger", + installationRole: "none", + roles: [ + { + name: "installer", + scopes: [{ resourceKind: "entry", actions: ["write"] }], + }, + { name: "viewer", scopes: [] }, + ], + }), + ), + http.post( + rpc("InstallationService", "InstallSolution"), + async ({ request }) => { + installed = await request.json(); + return HttpResponse.json({ + id: "bbbbbbbb-0000-4000-8000-000000000002", + orgId: "org-1", + targetId: LEDGER_TARGET, + status: "INSTALLATION_STATUS_ACTIVE", + }); + }, + ), + ); + renderInApp(); + fireEvent.click( + await screen.findByRole("button", { name: "Install ledger" }), + ); + const dialog = await screen.findByRole("dialog"); + await within(dialog).findByText("Module access"); + // The module's own declaration decides its standing access; nothing + // asks for an organisation role. + expect( + within(dialog).getByText( + "This module receives no standing machine access.", + ), + ).toBeTruthy(); + expect( + within(dialog).queryByRole("combobox", { name: "Agent role" }), + ).toBeNull(); + const roles = within(dialog).getByRole("region", { name: "Roles" }); + expect(within(roles).getByText(/installer:/)).toBeTruthy(); + expect( + within(roles).getByText("viewer: No data access declared."), + ).toBeTruthy(); + + const install = within(dialog).getByRole("button", { + name: "Install", + }) as HTMLButtonElement; + expect(install.disabled).toBe(true); + fireEvent.click(within(dialog).getByRole("checkbox")); + expect(install.disabled).toBe(false); + fireEvent.click(install); + + await waitFor(() => expect(installed).toBeDefined()); + expect(installed).toEqual({ + orgId: "org-1", + targetId: LEDGER_TARGET, + approval: LEDGER_APPROVAL, + agentIdentifier: "acme/ledger:0.9.1", + }); + await waitFor(() => + expect(push).toHaveBeenCalledWith( + "/admin/solutions/bbbbbbbb-0000-4000-8000-000000000002", + ), + ); + }); + + it("keeps a long role label inside the form so Install stays in view", async () => { + const long = { + id: "cccccccc-0000-4000-8000-000000000003", + name: "Module viewer", + orgId: "org-1", + permissions: Array.from({ length: 12 }, (_, index) => ({ + resource: `resource-with-a-long-name-${index}`, + action: "read", + })), + }; + const label = `Module viewer — ${long.permissions + .map((p) => `${p.resource}:${p.action}`) + .join(", ")}`; + serve({ rolesBody: { roles: [...roles.roles, long] } }); + renderInApp(); + fireEvent.click( + await screen.findByRole("button", { name: "Install ledger" }), + ); + const dialog = await screen.findByRole("dialog"); + await choose(dialog, "Agent role", label); + const trigger = within(dialog).getByRole("combobox", { + name: "Agent role", + }); + // happy-dom computes no layout, so this holds the CSS that bounds it: + // the form may shrink inside the dialog's grid, the trigger may shrink + // inside the form, and the label truncates with its full text on hover. + expect(trigger.closest("form")?.classList.contains("min-w-0")).toBe(true); + expect(trigger.classList.contains("min-w-0")).toBe(true); + expect(trigger.getAttribute("title")).toBe(label); + const shown = within(trigger).getByText(label); + expect(shown.classList.contains("truncate")).toBe(true); + expect(shown.closest('[data-slot="select-value"]')?.classList).toContain( + "min-w-0", + ); + expect( + within(dialog).getByRole("button", { name: "Install" }).closest("form"), + ).toBe(trigger.closest("form")); + }); +}); + // HOST-INST-002, HOST-INST-004, HOST-INST-005: no grants while unacknowledged; // a refusal remains visible and Retry reuses the same installation identity. it("shows pending and failed installations and retries the failed step", async () => { diff --git a/module/services/frontend/code/src/features/solution-installations/ui/solution-installations-page.tsx b/module/services/frontend/code/src/features/solution-installations/ui/solution-installations-page.tsx index 94b7b9939..ca706efce 100644 --- a/module/services/frontend/code/src/features/solution-installations/ui/solution-installations-page.tsx +++ b/module/services/frontend/code/src/features/solution-installations/ui/solution-installations-page.tsx @@ -9,6 +9,7 @@ import { EmptyState } from "@/components/empty-state"; import { OrgSelector } from "@/components/org-selector"; import { type AvailableSolution, + type Installation, InstallationStatus, } from "@/gen/saas/accounts/v1/installations_pb"; import { useAuth } from "@/lib/auth"; @@ -37,6 +38,7 @@ import { CardDescription, CardHeader, CardTitle, + DelayedLoading, Spinner, Table, TableBody, @@ -47,6 +49,7 @@ import { useLoadingPhase, } from "@/shared/ui"; import { + agentIdentifierFor, availableRows, type FactView, hostRefusal, @@ -55,6 +58,7 @@ import { type TeamGrantRow, } from "../model/installations"; import { readinessSummary } from "../model/readiness"; +import type { InstallSolutionValues } from "../model/schemas"; import { useGrantInstallationToTeam, useInstallModule, @@ -66,7 +70,9 @@ import { import { useAvailableSolutions, useInstallations } from "../service/queries"; import { GrantTeamDialog } from "./grant-team-dialog"; import { InstallModuleDialog } from "./install-module-dialog"; +import { InstallModulePageDialog } from "./install-module-page-dialog"; import { InstallSolutionDialog } from "./install-solution-dialog"; +import { useRolesDeclaredReview } from "./when-roles-declared"; /** * One fact. A gap reads as its label in muted italics with what it means on @@ -127,6 +133,20 @@ export function SolutionInstallationsPage({ ); } +/** A module page's install: its release's agent and no agent role, which its declaration selects. */ +function moduleWithoutAgentRole( + target: AvailableSolution, +): InstallSolutionValues { + return { + agentIdentifier: agentIdentifierFor(target), + displayName: "", + rootScopeLabel: "", + roleId: "", + allowedAudiences: "", + allowedScopes: "", + }; +} + function SolutionInstallationsForOrganization({ orgId, showModules, @@ -141,6 +161,39 @@ function SolutionInstallationsForOrganization({ // This owner survives dismissal of the form and reports every host result. const install = useInstallModule(orgId); const combinedInstall = useInstallReviewedSolution(orgId); + // A target that declares roles installs through a review of them, as a + // solution does; a module that declares none keeps its agent-role form. + const { + declared: rolesDeclared, + error: rolesUnread, + retry: readRolesAgain, + } = useRolesDeclaredReview(installing?.routeAlias ?? "", installing?.kind); + const installModule = ( + target: AvailableSolution, + values: InstallSolutionValues, + { onSuccess }: { onSuccess: (installation: Installation) => void }, + ) => + install.mutate( + { + targetId: target.targetId, + generation: target.appliedGeneration, + expectedRevision: (installed.data ?? []).reduce( + (revision, row) => + row.installation?.targetId === target.targetId && + row.installation.revision > revision + ? row.installation.revision + : revision, + BigInt(0), + ), + operationId: crypto.randomUUID(), + values, + }, + { + onSuccess, + onError: (error) => + toast.error(`The host refused the install: ${hostRefusal(error)}`), + }, + ); return (
@@ -197,6 +250,7 @@ function SolutionInstallationsForOrganization({ showModules={showModules} query={available} installing={ + (Boolean(installing) && rolesDeclared === undefined) || install.isPending || combinedInstall.isPending || !installed.isSuccess || @@ -216,7 +270,27 @@ function SolutionInstallationsForOrganization({ )} - {installing && installing.kind !== "module" && ( + {/* A module's roles are read before its form opens: which form is + the authority's answer, not the target's kind. */} + + {installing && rolesUnread !== undefined && ( + setInstalling(null)} + > + {hostRefusal(rolesUnread)} Which install form applies depends on them.{" "} + + + )} + {installing?.kind === "solution" && ( setInstalling(null)} /> )} - {installing?.kind === "module" && ( - { - install.mutate( - { - targetId: installing.targetId, - generation: installing.appliedGeneration, - expectedRevision: (installed.data ?? []).reduce( - (revision, row) => - row.installation?.targetId === installing.targetId && - row.installation.revision > revision - ? row.installation.revision - : revision, - BigInt(0), - ), - operationId: crypto.randomUUID(), - values, + onSubmit={() => + // The module's signed installation role decides its standing + // access, so no agent role is sent; its roles are granted to + // teams in Set up, where the installation now lands. + installModule(installing, moduleWithoutAgentRole(installing), { + onSuccess: (installation) => { + toast.success( + `${installing.routeAlias} installation ${installation.status === InstallationStatus.ACTIVE ? "active" : "pending"}. Choose a team for each role in Set up.`, + ); + setInstalling(null); + router.push(`/admin/solutions/${installation.id}`); }, - { - onSuccess: () => { - toast.success( - `${installing.routeAlias} installation pending`, - ); - setInstalling(null); - }, - onError: (error) => - toast.error( - `The host refused the install: ${hostRefusal(error)}`, - ), + }) + } + onClose={() => setInstalling(null)} + /> + )} + {installing?.kind === "module" && rolesDeclared === false && ( + + installModule(installing, values, { + onSuccess: () => { + toast.success(`${installing.routeAlias} installation pending`); + setInstalling(null); }, - ); - }} + }) + } onClose={() => setInstalling(null)} /> )} diff --git a/module/services/frontend/code/src/features/solution-installations/ui/when-roles-declared.test.tsx b/module/services/frontend/code/src/features/solution-installations/ui/when-roles-declared.test.tsx index 0adea7177..2667eeea7 100644 --- a/module/services/frontend/code/src/features/solution-installations/ui/when-roles-declared.test.tsx +++ b/module/services/frontend/code/src/features/solution-installations/ui/when-roles-declared.test.tsx @@ -1,4 +1,5 @@ import { create } from "@bufbuild/protobuf"; +import { Code, ConnectError } from "@connectrpc/connect"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { cleanup, render, screen } from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; @@ -17,7 +18,10 @@ vi.mock("@/lib/auth", () => ({ useAuth: () => ({ organizationId: "example-org" }), })); -import { WhenRolesDeclared } from "./when-roles-declared"; +import { + useRolesDeclaredReview, + WhenRolesDeclared, +} from "./when-roles-declared"; afterEach(() => { cleanup(); @@ -64,10 +68,44 @@ describe("WhenRolesDeclared", () => { // The authority refuses the offer of a module that declares no roles. it("shows nothing for a module whose offer the authority refuses", async () => { reviewOffer.mockRejectedValue( - new Error("not a composed solution or a module page declaring roles"), + new ConnectError( + "not a composed solution or a module page declaring roles", + Code.NotFound, + ), ); renderGate("module"); await vi.waitFor(() => expect(reviewOffer).toHaveBeenCalled()); expect(screen.queryByText("Setup")).toBeNull(); }); + + // Only NotFound means "declares no roles"; any other failure is no answer. + it.each([ + ["NotFound", Code.NotFound, "declared: false"], + ["Unavailable", Code.Unavailable, "declared: unknown, error: try later"], + ])( + "reads %s as an answer only when it is NotFound", + async (_name, code, expected) => { + reviewOffer.mockRejectedValue(new ConnectError("try later", code)); + function Probe() { + const { declared, error } = useRolesDeclaredReview( + "example-target", + "module", + ); + if (declared === undefined && !error) return reading; + return ( + + {declared === undefined + ? `declared: unknown, error: ${ConnectError.from(error).rawMessage}` + : `declared: ${declared}`} + + ); + } + render( + + + , + ); + expect(await screen.findByText(expected)).toBeTruthy(); + }, + ); }); diff --git a/module/services/frontend/code/src/features/solution-installations/ui/when-roles-declared.tsx b/module/services/frontend/code/src/features/solution-installations/ui/when-roles-declared.tsx index 48a56a938..677a8a6bd 100644 --- a/module/services/frontend/code/src/features/solution-installations/ui/when-roles-declared.tsx +++ b/module/services/frontend/code/src/features/solution-installations/ui/when-roles-declared.tsx @@ -1,6 +1,6 @@ "use client"; -import { createClient } from "@connectrpc/connect"; +import { Code, ConnectError, createClient } from "@connectrpc/connect"; import { useQuery } from "@tanstack/react-query"; import type { ReactNode } from "react"; import { OfferService } from "@/gen/host/installation/v1/installation_pb"; @@ -10,16 +10,18 @@ import { apiTransport } from "@/lib/connect/transport"; const offers = createClient(OfferService, apiTransport); /** - * Whether a target declares installation roles. A solution always does: the - * shared authority core composes none without an installer role. A module - * does only when it declares a page and roles; the authority's offer review - * is the answer, and a module it refuses (one that declares no roles) does - * not. Undefined while a module's answer is still being read. + * Whether a target declares installation roles, with the read's own failure. + * A solution always does: the shared authority core composes none without an + * installer role. A module does only when it declares a page and roles; the + * authority's offer review is the answer, and only its NotFound (a module + * that declares no roles) means it does not. Any other failure is not an + * answer: `declared` stays undefined and `error` says why, so a transient + * refusal never passes a module page off as one without roles. */ -export function useRolesDeclared( +export function useRolesDeclaredReview( target: string, kind?: string, -): boolean | undefined { +): { declared: boolean | undefined; error: unknown; retry: () => void } { const { organizationId = "" } = useAuth(); const review = useQuery({ queryKey: ["solution-declares-roles", organizationId, target], @@ -29,9 +31,26 @@ export function useRolesDeclared( retry: false, refetchOnWindowFocus: false, }); - if (kind !== "module") return kind === "solution"; - if (review.isError) return false; - return review.data; + const retry = () => void review.refetch(); + if (kind !== "module") + return { declared: kind === "solution", error: undefined, retry }; + if (review.isError) { + const declaresNone = ConnectError.from(review.error).code === Code.NotFound; + return { + declared: declaresNone ? false : undefined, + error: declaresNone ? undefined : review.error, + retry, + }; + } + return { declared: review.data, error: undefined, retry }; +} + +/** Whether a target declares installation roles; undefined while unknown. */ +export function useRolesDeclared( + target: string, + kind?: string, +): boolean | undefined { + return useRolesDeclaredReview(target, kind).declared; } /** diff --git a/module/services/frontend/code/src/gen/saas/accounts/v1/installations_pb.ts b/module/services/frontend/code/src/gen/saas/accounts/v1/installations_pb.ts index c52d5e13f..2f3663905 100644 --- a/module/services/frontend/code/src/gen/saas/accounts/v1/installations_pb.ts +++ b/module/services/frontend/code/src/gen/saas/accounts/v1/installations_pb.ts @@ -19,7 +19,7 @@ import type { Message } from "@bufbuild/protobuf"; * Describes the file saas/accounts/v1/installations.proto. */ export const file_saas_accounts_v1_installations: GenFile = /*@__PURE__*/ - fileDesc("CiRzYWFzL2FjY291bnRzL3YxL2luc3RhbGxhdGlvbnMucHJvdG8SEHNhYXMuYWNjb3VudHMudjEitwQKDEluc3RhbGxhdGlvbhIUCgJpZBgBIAEoCUIIukgFcgOwAQESGAoGb3JnX2lkGAIgASgJQgi6SAVyA7ABARIkChJhZ2VudF9wcmluY2lwYWxfaWQYAyABKAlCCLpIBXIDsAEBEiQKEm93bmVyX3ByaW5jaXBhbF9pZBgFIAEoCUIIukgFcgOwAQESHgoWY29fb3duZXJfcHJpbmNpcGFsX2lkcxgGIAMoCRIkChJyb290X3Njb3BlX25vZGVfaWQYByABKAlCCLpIBXIDsAEBEjQKBnN0YXR1cxgIIAEoDjIkLnNhYXMuYWNjb3VudHMudjEuSW5zdGFsbGF0aW9uU3RhdHVzEi4KCmNyZWF0ZWRfYXQYCSABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEjMKCnJldm9rZWRfYXQYCiABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wSACIAQESHgoJdGFyZ2V0X2lkGAsgASgJQgu6SAjYAQFyA7ABARIgCg5yZXZva2VkX3JlYXNvbhgMIAEoCUIIukgFcgMYgAQSIAoOZmFpbHVyZV9yZWFzb24YDSABKAlCCLpIBXIDGIAEEhAKCHJldmlzaW9uGA4gASgEEhMKC3NldHVwX3N0YXRlGA8gASgJEhUKDXNldHVwX21lc3NhZ2UYECABKAlCDQoLX3Jldm9rZWRfYXRKBAgEEAVSE3NvbHV0aW9uX2lkZW50aWZpZXIimQQKFkluc3RhbGxTb2x1dGlvblJlcXVlc3QSGAoGb3JnX2lkGAEgASgJQgi6SAVyA7ABARIkChBhZ2VudF9pZGVudGlmaWVyGAIgASgJQgq6SAdyBRABGIAEEh4KDGRpc3BsYXlfbmFtZRgEIAEoCUIIukgFcgMYgAQSIgoQcm9vdF9zY29wZV9sYWJlbBgGIAEoCUIIukgFcgMYgAQSGQoHcm9sZV9pZBgHIAEoCUIIukgFcgOwAQESJwoSb3duZXJfcHJpbmNpcGFsX2lkGAggASgJQgu6SAjYAQFyA7ABARIvChZjb19vd25lcl9wcmluY2lwYWxfaWRzGAkgAygJQg+6SAySAQkQICIFcgOwAQESKgoRYWxsb3dlZF9hdWRpZW5jZXMYCiADKAlCD7pIDJIBCRBAIgVyAxiABBInCg5hbGxvd2VkX3Njb3BlcxgLIAMoCUIPukgMkgEJEEAiBXIDGIABEhsKCXRhcmdldF9pZBgMIAEoCUIIukgFcgOwAQESHwoFc2NvcGUYDSABKAlCC7pICHIGEAIogIBASACIAQESNwoIYXBwcm92YWwYDiABKAsyJS5ob3N0Lmluc3RhbGxhdGlvbi52MS5Db25zZW50QXBwcm92YWxCCAoGX3Njb3BlSgQIAxAESgQIBRAGUhNzb2x1dGlvbl9pZGVudGlmaWVyUg9yb290X3Njb3BlX3BhdGgidAoZTmFycm93SW5zdGFsbGF0aW9uUmVxdWVzdBIYCgZvcmdfaWQYASABKAlCCLpIBXIDsAEBEiEKD2luc3RhbGxhdGlvbl9pZBgCIAEoCUIIukgFcgOwAQESGgoFc2NvcGUYAyABKAlCC7pICHIGEAIogIBAIlcKGFJldHJ5SW5zdGFsbGF0aW9uUmVxdWVzdBIYCgZvcmdfaWQYASABKAlCCLpIBXIDsAEBEiEKD2luc3RhbGxhdGlvbl9pZBgCIAEoCUIIukgFcgOwAQEiVwoYVW5pbnN0YWxsU29sdXRpb25SZXF1ZXN0EhgKBm9yZ19pZBgBIAEoCUIIukgFcgOwAQESIQoPaW5zdGFsbGF0aW9uX2lkGAIgASgJQgi6SAVyA7ABASK+AQokVHJhbnNmZXJJbnN0YWxsYXRpb25Pd25lcnNoaXBSZXF1ZXN0EhgKBm9yZ19pZBgBIAEoCUIIukgFcgOwAQESIQoPaW5zdGFsbGF0aW9uX2lkGAIgASgJQgi6SAVyA7ABARIoChZuZXdfb3duZXJfcHJpbmNpcGFsX2lkGAMgASgJQgi6SAVyA7ABARIvChZjb19vd25lcl9wcmluY2lwYWxfaWRzGAQgAygJQg+6SAySAQkQICIFcgOwAQEiVQoWR2V0SW5zdGFsbGF0aW9uUmVxdWVzdBIYCgZvcmdfaWQYASABKAlCCLpIBXIDsAEBEiEKD2luc3RhbGxhdGlvbl9pZBgCIAEoCUIIukgFcgOwAQEihQEKF0dldEluc3RhbGxhdGlvblJlc3BvbnNlEjQKDGluc3RhbGxhdGlvbhgBIAEoCzIeLnNhYXMuYWNjb3VudHMudjEuSW5zdGFsbGF0aW9uEjQKBmhlYWx0aBgCIAEoDjIkLnNhYXMuYWNjb3VudHMudjEuSW5zdGFsbGF0aW9uSGVhbHRoIp0BChhMaXN0SW5zdGFsbGF0aW9uc1JlcXVlc3QSGAoGb3JnX2lkGAEgASgJQgi6SAVyA7ABARI0CgZzdGF0dXMYAiABKA4yJC5zYWFzLmFjY291bnRzLnYxLkluc3RhbGxhdGlvblN0YXR1cxIdCglwYWdlX3NpemUYAyABKAVCCrpIBxoFGPQDKAASEgoKcGFnZV90b2tlbhgEIAEoCSKBAQoTSW5zdGFsbGF0aW9uU3VtbWFyeRI0CgxpbnN0YWxsYXRpb24YASABKAsyHi5zYWFzLmFjY291bnRzLnYxLkluc3RhbGxhdGlvbhI0CgZoZWFsdGgYAiABKA4yJC5zYWFzLmFjY291bnRzLnYxLkluc3RhbGxhdGlvbkhlYWx0aCJyChlMaXN0SW5zdGFsbGF0aW9uc1Jlc3BvbnNlEjwKDWluc3RhbGxhdGlvbnMYASADKAsyJS5zYWFzLmFjY291bnRzLnYxLkluc3RhbGxhdGlvblN1bW1hcnkSFwoPbmV4dF9wYWdlX3Rva2VuGAIgASgJIq0CChdSZXZpZXdlZFNvbHV0aW9uSW5zdGFsbBIOCgZvcmdfaWQYASABKAkSEAoIYWN0b3JfaWQYAiABKAkSEQoJdGFyZ2V0X2lkGAMgASgJEg4KBnRhcmdldBgEIAEoCRISCgpiaW5kaW5nX2lkGAUgASgJEg8KB3JlbGVhc2UYBiABKAkSFwoPZGVsaXZlcnlfZGlnZXN0GAcgASgJEjcKCGFwcHJvdmFsGAggASgLMiUuaG9zdC5pbnN0YWxsYXRpb24udjEuQ29uc2VudEFwcHJvdmFsEhkKEWluc3RhbGxhdGlvbl9yb2xlGAkgASgJEjsKB21vZHVsZXMYCiADKAsyKi5zYWFzLmFjY291bnRzLnYxLlJldmlld2VkU3VwcG9ydGluZ01vZHVsZSKiAgoYUmV2aWV3ZWRTdXBwb3J0aW5nTW9kdWxlEhEKCXByaW5jaXBhbBgBIAEoCRIRCgl0YXJnZXRfaWQYAiABKAkSDgoGdGFyZ2V0GAMgASgJEhIKCmJpbmRpbmdfaWQYBCABKAkSDwoHcmVsZWFzZRgFIAEoCRIXCg9kZWxpdmVyeV9kaWdlc3QYBiABKAkSGQoRaW5zdGFsbGF0aW9uX3JvbGUYByABKAkSHQoVcmV1c2VfaW5zdGFsbGF0aW9uX2lkGAggASgJEg0KBXNjb3BlGAkgASgJEjcKCGFwcHJvdmFsGAogASgLMiUuaG9zdC5pbnN0YWxsYXRpb24udjEuQ29uc2VudEFwcHJvdmFsEhAKCHJlcXVpcmVkGAsgASgIIlUKHFJldmlld1NvbHV0aW9uSW5zdGFsbFJlcXVlc3QSGAoGb3JnX2lkGAEgASgJQgi6SAVyA7ABARIbCgl0YXJnZXRfaWQYAiABKAlCCLpIBXIDsAEBIn0KHkluc3RhbGxSZXZpZXdlZFNvbHV0aW9uUmVxdWVzdBIYCgZvcmdfaWQYASABKAlCCLpIBXIDsAEBEkEKBnJldmlldxgCIAEoCzIpLnNhYXMuYWNjb3VudHMudjEuUmV2aWV3ZWRTb2x1dGlvbkluc3RhbGxCBrpIA8gBASJzCiRMaXN0T3JnYW5pemF0aW9uSW5zdGFsbGF0aW9uc1JlcXVlc3QSGAoGb3JnX2lkGAEgASgJQgi6SAVyA7ABARIdCglwYWdlX3NpemUYAiABKAVCCrpIBxoFGPQDKAASEgoKcGFnZV90b2tlbhgDIAEoCSLIAwoYT3JnYW5pemF0aW9uSW5zdGFsbGF0aW9uEjQKDGluc3RhbGxhdGlvbhgBIAEoCzIeLnNhYXMuYWNjb3VudHMudjEuSW5zdGFsbGF0aW9uEjQKBmhlYWx0aBgCIAEoDjIkLnNhYXMuYWNjb3VudHMudjEuSW5zdGFsbGF0aW9uSGVhbHRoEjMKBnRhcmdldBgDIAEoCzIjLnNhYXMuYWNjb3VudHMudjEuQXZhaWxhYmxlU29sdXRpb24SFwoPcm9vdF9zY29wZV9wYXRoGAQgASgJEjwKDWdyYW50ZWRfdGVhbXMYBSADKAsyJS5zYWFzLmFjY291bnRzLnYxLkNvbGxlY3Rpb25SZWFkR3JhbnQSPgoPaW5oZXJpdGVkX3RlYW1zGAYgAygLMiUuc2Fhcy5hY2NvdW50cy52MS5Db2xsZWN0aW9uUmVhZEdyYW50EjoKCXJlYWRpbmVzcxgHIAEoCzInLnNhYXMuYWNjb3VudHMudjEuSW5zdGFsbGF0aW9uUmVhZGluZXNzEjgKD3JlYWNoZWRfbW9kdWxlcxgIIAMoCzIfLnNhYXMuYWNjb3VudHMudjEuUmVhY2hlZE1vZHVsZSLtAQoSUmVhZGluZXNzQ29uZGl0aW9uEjYKBGtpbmQYASABKA4yKC5zYWFzLmFjY291bnRzLnYxLlJlYWRpbmVzc0NvbmRpdGlvbktpbmQSDwoHc3ViamVjdBgCIAEoCRIOCgZkZXRhaWwYAyABKAkSOAoKY2xlYXJlZF9ieRgEIAEoDjIkLnNhYXMuYWNjb3VudHMudjEuUmVhZGluZXNzQ2xlYXJlZEJ5EjEKBmFjdGlvbhgFIAEoDjIhLnNhYXMuYWNjb3VudHMudjEuUmVhZGluZXNzQWN0aW9uEhEKCXNvdXJjZV9pZBgGIAEoCSJbChVJbnN0YWxsYXRpb25SZWFkaW5lc3MSDQoFcmVhZHkYASABKAgSMwoFdW5tZXQYAiADKAsyJC5zYWFzLmFjY291bnRzLnYxLlJlYWRpbmVzc0NvbmRpdGlvbiJPCg1SZWFjaGVkTW9kdWxlEhAKCGF1ZGllbmNlGAEgASgJEhcKD2luc3RhbGxhdGlvbl9pZBgCIAEoCRITCgtyb3V0ZV9hbGlhcxgDIAEoCSKDAQolTGlzdE9yZ2FuaXphdGlvbkluc3RhbGxhdGlvbnNSZXNwb25zZRJBCg1pbnN0YWxsYXRpb25zGAEgAygLMiouc2Fhcy5hY2NvdW50cy52MS5Pcmdhbml6YXRpb25JbnN0YWxsYXRpb24SFwoPbmV4dF9wYWdlX3Rva2VuGAIgASgJIpMBCh5HcmFudEluc3RhbGxhdGlvblRvVGVhbVJlcXVlc3QSGAoGb3JnX2lkGAEgASgJQgi6SAVyA7ABARIhCg9pbnN0YWxsYXRpb25faWQYAiABKAlCCLpIBXIDsAEBEhkKB3RlYW1faWQYAyABKAlCCLpIBXIDsAEBEhkKB3JvbGVfaWQYBCABKAlCCLpIBXIDsAEBInUKE0NhcnJpZWRNb2R1bGVBY2Nlc3MSEAoIYXVkaWVuY2UYASABKAkSFwoPaW5zdGFsbGF0aW9uX2lkGAIgASgJEhIKCnNjb3BlX3BhdGgYAyABKAkSDwoHcm9sZV9pZBgEIAEoCRIOCgZzY29wZXMYBSADKAkiuwEKH0dyYW50SW5zdGFsbGF0aW9uVG9UZWFtUmVzcG9uc2USKwoFZ3JhbnQYASABKAsyHC5zYWFzLmFjY291bnRzLnYxLlNjb3BlR3JhbnQSNgoHY2FycmllZBgCIAMoCzIlLnNhYXMuYWNjb3VudHMudjEuQ2FycmllZE1vZHVsZUFjY2VzcxIzCgV1bm1ldBgDIAMoCzIkLnNhYXMuYWNjb3VudHMudjEuUmVhZGluZXNzQ29uZGl0aW9uIpYBCiFSZXZva2VJbnN0YWxsYXRpb25Gcm9tVGVhbVJlcXVlc3QSGAoGb3JnX2lkGAEgASgJQgi6SAVyA7ABARIhCg9pbnN0YWxsYXRpb25faWQYAiABKAlCCLpIBXIDsAEBEhkKB3RlYW1faWQYAyABKAlCCLpIBXIDsAEBEhkKB3JvbGVfaWQYBCABKAlCCLpIBXIDsAEBIoQCChFBdmFpbGFibGVTb2x1dGlvbhIbCgl0YXJnZXRfaWQYASABKAlCCLpIBXIDsAEBEhIKCmJpbmRpbmdfaWQYAiABKAkSEwoLcm91dGVfYWxpYXMYAyABKAkSGQoRcmVsZWFzZV9wdWJsaXNoZXIYBCABKAkSFAoMcmVsZWFzZV9uYW1lGAUgASgJEhcKD3JlbGVhc2VfdmVyc2lvbhgGIAEoCRIZChFvcGVuZWRfZ2VuZXJhdGlvbhgHIAEoBBIaChJhcHBsaWVkX2dlbmVyYXRpb24YCCABKAQSEQoJaW5zdGFsbGVkGAkgASgIEhUKBGtpbmQYCiABKAlCB7pIBHICGCAibAodTGlzdEF2YWlsYWJsZVNvbHV0aW9uc1JlcXVlc3QSGAoGb3JnX2lkGAEgASgJQgi6SAVyA7ABARIdCglwYWdlX3NpemUYAiABKAVCCrpIBxoFGPQDKAASEgoKcGFnZV90b2tlbhgDIAEoCSJxCh5MaXN0QXZhaWxhYmxlU29sdXRpb25zUmVzcG9uc2USNgoJc29sdXRpb25zGAEgAygLMiMuc2Fhcy5hY2NvdW50cy52MS5BdmFpbGFibGVTb2x1dGlvbhIXCg9uZXh0X3BhZ2VfdG9rZW4YAiABKAkquwEKEkluc3RhbGxhdGlvblN0YXR1cxIjCh9JTlNUQUxMQVRJT05fU1RBVFVTX1VOU1BFQ0lGSUVEEAASHgoaSU5TVEFMTEFUSU9OX1NUQVRVU19BQ1RJVkUQARIfChtJTlNUQUxMQVRJT05fU1RBVFVTX1JFVk9LRUQQAhIfChtJTlNUQUxMQVRJT05fU1RBVFVTX1BFTkRJTkcQAxIeChpJTlNUQUxMQVRJT05fU1RBVFVTX0ZBSUxFRBAEKoQCChJJbnN0YWxsYXRpb25IZWFsdGgSIwofSU5TVEFMTEFUSU9OX0hFQUxUSF9VTlNQRUNJRklFRBAAEh8KG0lOU1RBTExBVElPTl9IRUFMVEhfSEVBTFRIWRABEikKJUlOU1RBTExBVElPTl9IRUFMVEhfTk9fRUxJR0lCTEVfT1dORVIQAhIlCiFJTlNUQUxMQVRJT05fSEVBTFRIX0FHRU5UX1JFVk9LRUQQAxImCiJJTlNUQUxMQVRJT05fSEVBTFRIX0FHRU5UX0RJU0FCTEVEEAQSLgoqSU5TVEFMTEFUSU9OX0hFQUxUSF9TVEFORElOR19HUkFOVF9NSVNTSU5HEAUq8wMKFlJlYWRpbmVzc0NvbmRpdGlvbktpbmQSKAokUkVBRElORVNTX0NPTkRJVElPTl9LSU5EX1VOU1BFQ0lGSUVEEAASMQotUkVBRElORVNTX0NPTkRJVElPTl9LSU5EX0lOU1RBTExfU1RFUF9QRU5ESU5HEAESMAosUkVBRElORVNTX0NPTkRJVElPTl9LSU5EX0lOU1RBTExfU1RFUF9GQUlMRUQQAhItCilSRUFESU5FU1NfQ09ORElUSU9OX0tJTkRfUkVDT1JEX1VOSEVBTFRIWRADEjUKMVJFQURJTkVTU19DT05ESVRJT05fS0lORF9NT0RVTEVfUFJJTkNJUEFMX09NSVRURUQQBBI2CjJSRUFESU5FU1NfQ09ORElUSU9OX0tJTkRfT1BFUkFUSU9OX0JJTkRJTkdfT01JVFRFRBAFEjEKLVJFQURJTkVTU19DT05ESVRJT05fS0lORF9TT1VSQ0VfTk9UX0RFTEVHQVRFRBAGEjkKNVJFQURJTkVTU19DT05ESVRJT05fS0lORF9SRUFDSEVEX01PRFVMRV9OT1RfSU5TVEFMTEVEEAcSPgo6UkVBRElORVNTX0NPTkRJVElPTl9LSU5EX0NBUlJJRURfTU9EVUxFX0FDQ0VTU19OT1RfREVSSVZFRBAIKtUBChJSZWFkaW5lc3NDbGVhcmVkQnkSJAogUkVBRElORVNTX0NMRUFSRURfQllfVU5TUEVDSUZJRUQQABIfChtSRUFESU5FU1NfQ0xFQVJFRF9CWV9WSUVXRVIQARIrCidSRUFESU5FU1NfQ0xFQVJFRF9CWV9PUkdBTklaQVRJT05fQURNSU4QAhIqCiZSRUFESU5FU1NfQ0xFQVJFRF9CWV9QTEFURk9STV9PUEVSQVRPUhADEh8KG1JFQURJTkVTU19DTEVBUkVEX0JZX05PQk9EWRAEKp8CCg9SZWFkaW5lc3NBY3Rpb24SIAocUkVBRElORVNTX0FDVElPTl9VTlNQRUNJRklFRBAAEikKJVJFQURJTkVTU19BQ1RJT05fTk9ORV9BVF9USElTX1NVUkZBQ0UQARInCiNSRUFESU5FU1NfQUNUSU9OX1JFVFJZX0lOU1RBTExfU1RFUBACEiUKIVJFQURJTkVTU19BQ1RJT05fUkVDT05ORUNUX1NPVVJDRRADEicKI1JFQURJTkVTU19BQ1RJT05fVFJBTlNGRVJfT1dORVJTSElQEAQSKwonUkVBRElORVNTX0FDVElPTl9JTlNUQUxMX1JFQUNIRURfTU9EVUxFEAUSGQoVUkVBRElORVNTX0FDVElPTl9XQUlUEAYyrhUKE0luc3RhbGxhdGlvblNlcnZpY2USxgEKFVJldmlld1NvbHV0aW9uSW5zdGFsbBIuLnNhYXMuYWNjb3VudHMudjEuUmV2aWV3U29sdXRpb25JbnN0YWxsUmVxdWVzdBopLnNhYXMuYWNjb3VudHMudjEuUmV2aWV3ZWRTb2x1dGlvbkluc3RhbGwiUsLzGCIIAhAEKgwKBm9yZ19pZBACGAEwAToCEAFAAUgDUANYA2ABgtPkkwImOgEqIiEvdjEvc29sdXRpb24taW5zdGFsbGF0aW9ucy9yZXZpZXcSuAEKF0luc3RhbGxSZXZpZXdlZFNvbHV0aW9uEjAuc2Fhcy5hY2NvdW50cy52MS5JbnN0YWxsUmV2aWV3ZWRTb2x1dGlvblJlcXVlc3QaHi5zYWFzLmFjY291bnRzLnYxLkluc3RhbGxhdGlvbiJLwvMYIggCEAQqDAoGb3JnX2lkEAIYATABOgIQAUABSARQA1gDYAGC0+STAh86ASoiGi92MS9zb2x1dGlvbi1pbnN0YWxsYXRpb25zEroBCg9JbnN0YWxsU29sdXRpb24SKC5zYWFzLmFjY291bnRzLnYxLkluc3RhbGxTb2x1dGlvblJlcXVlc3QaHi5zYWFzLmFjY291bnRzLnYxLkluc3RhbGxhdGlvbiJdwvMYPQgCEAQqDAoGb3JnX2lkEAIYATABOh0KGXNhYXMuaW5zdGFsbGF0aW9uLmNyZWF0ZWQQAkABSARQA1gDYAGC0+STAhY6ASoiES92MS9pbnN0YWxsYXRpb25zEtIBChFVbmluc3RhbGxTb2x1dGlvbhIqLnNhYXMuYWNjb3VudHMudjEuVW5pbnN0YWxsU29sdXRpb25SZXF1ZXN0GhYuZ29vZ2xlLnByb3RvYnVmLkVtcHR5InnC8xg9CAIQBCoMCgZvcmdfaWQQAhgBMAE6HQoZc2Fhcy5pbnN0YWxsYXRpb24ucmV2b2tlZBACQAFIBFADWANgAYLT5JMCMjoBKiItL3YxL2luc3RhbGxhdGlvbnMve2luc3RhbGxhdGlvbl9pZH06dW5pbnN0YWxsEtIBChJOYXJyb3dJbnN0YWxsYXRpb24SKy5zYWFzLmFjY291bnRzLnYxLk5hcnJvd0luc3RhbGxhdGlvblJlcXVlc3QaFi5nb29nbGUucHJvdG9idWYuRW1wdHkid8LzGD4IAhAEKgwKBm9yZ19pZBACGAEwAToeChpzYWFzLmluc3RhbGxhdGlvbi5uYXJyb3dlZBACQAFIBFADWANgAYLT5JMCLzoBKiIqL3YxL2luc3RhbGxhdGlvbnMve2luc3RhbGxhdGlvbl9pZH06bmFycm93EtYBChFSZXRyeUluc3RhbGxhdGlvbhIqLnNhYXMuYWNjb3VudHMudjEuUmV0cnlJbnN0YWxsYXRpb25SZXF1ZXN0Gh4uc2Fhcy5hY2NvdW50cy52MS5JbnN0YWxsYXRpb24idcLzGD0IAhAEKgwKBm9yZ19pZBACGAEwATodChlzYWFzLmluc3RhbGxhdGlvbi5yZXRyaWVkEAJAAUgEUANYA2ABgtPkkwIuOgEqIikvdjEvaW5zdGFsbGF0aW9ucy97aW5zdGFsbGF0aW9uX2lkfTpyZXRyeRKJAgodVHJhbnNmZXJJbnN0YWxsYXRpb25Pd25lcnNoaXASNi5zYWFzLmFjY291bnRzLnYxLlRyYW5zZmVySW5zdGFsbGF0aW9uT3duZXJzaGlwUmVxdWVzdBoeLnNhYXMuYWNjb3VudHMudjEuSW5zdGFsbGF0aW9uIo8BwvMYSwgCEAQqDAoGb3JnX2lkEAIYATABOisKJ3NhYXMuaW5zdGFsbGF0aW9uLm93bmVyc2hpcF90cmFuc2ZlcnJlZBACQAFIBFADWANgAYLT5JMCOjoBKiI1L3YxL2luc3RhbGxhdGlvbnMve2luc3RhbGxhdGlvbl9pZH06dHJhbnNmZXJPd25lcnNoaXASuQEKD0dldEluc3RhbGxhdGlvbhIoLnNhYXMuYWNjb3VudHMudjEuR2V0SW5zdGFsbGF0aW9uUmVxdWVzdBopLnNhYXMuYWNjb3VudHMudjEuR2V0SW5zdGFsbGF0aW9uUmVzcG9uc2UiUcLzGCIIAhADKgwKBm9yZ19pZBACGAEwAToCEAFAAUgDUANYA2ABgtPkkwIlEiMvdjEvaW5zdGFsbGF0aW9ucy97aW5zdGFsbGF0aW9uX2lkfRKGAQoRTGlzdEluc3RhbGxhdGlvbnMSKi5zYWFzLmFjY291bnRzLnYxLkxpc3RJbnN0YWxsYXRpb25zUmVxdWVzdBorLnNhYXMuYWNjb3VudHMudjEuTGlzdEluc3RhbGxhdGlvbnNSZXNwb25zZSIYwvMYFAgDEAEwAToCEAFAAUgHUANYA2ABEsoBChZMaXN0QXZhaWxhYmxlU29sdXRpb25zEi8uc2Fhcy5hY2NvdW50cy52MS5MaXN0QXZhaWxhYmxlU29sdXRpb25zUmVxdWVzdBowLnNhYXMuYWNjb3VudHMudjEuTGlzdEF2YWlsYWJsZVNvbHV0aW9uc1Jlc3BvbnNlIk3C8xgiCAIQBCoMCgZvcmdfaWQQAhgBMAE6AhABQAFIA1ADWANgAYLT5JMCIRIfL3YxL2luc3RhbGxhdGlvbnM6bGlzdEF2YWlsYWJsZRLRAQodTGlzdE9yZ2FuaXphdGlvbkluc3RhbGxhdGlvbnMSNi5zYWFzLmFjY291bnRzLnYxLkxpc3RPcmdhbml6YXRpb25JbnN0YWxsYXRpb25zUmVxdWVzdBo3LnNhYXMuYWNjb3VudHMudjEuTGlzdE9yZ2FuaXphdGlvbkluc3RhbGxhdGlvbnNSZXNwb25zZSI/wvMYIggCEAQqDAoGb3JnX2lkEAIYATABOgIQAUABSANQA1gDYAGC0+STAhMSES92MS9pbnN0YWxsYXRpb25zEvABChdHcmFudEluc3RhbGxhdGlvblRvVGVhbRIwLnNhYXMuYWNjb3VudHMudjEuR3JhbnRJbnN0YWxsYXRpb25Ub1RlYW1SZXF1ZXN0GjEuc2Fhcy5hY2NvdW50cy52MS5HcmFudEluc3RhbGxhdGlvblRvVGVhbVJlc3BvbnNlInDC8xg4CAIQBCoMCgZvcmdfaWQQAhgBMAE6FgoSc2Fhcy5zY29wZS5ncmFudGVkEAJAAUgEUANYA2ABeAKC0+STAi46ASoiKS92MS9pbnN0YWxsYXRpb25zL3tpbnN0YWxsYXRpb25faWR9L3RlYW1zEu0BChpSZXZva2VJbnN0YWxsYXRpb25Gcm9tVGVhbRIzLnNhYXMuYWNjb3VudHMudjEuUmV2b2tlSW5zdGFsbGF0aW9uRnJvbVRlYW1SZXF1ZXN0GhYuZ29vZ2xlLnByb3RvYnVmLkVtcHR5IoEBwvMYOAgCEAQqDAoGb3JnX2lkEAIYATABOhYKEnNhYXMuc2NvcGUucmV2b2tlZBACQAFIBFADWANgAXgCgtPkkwI/OgEqIjovdjEvaW5zdGFsbGF0aW9ucy97aW5zdGFsbGF0aW9uX2lkfS90ZWFtcy97dGVhbV9pZH06cmV2b2tlQroBChRjb20uc2Fhcy5hY2NvdW50cy52MUISSW5zdGFsbGF0aW9uc1Byb3RvUAFaLGFjY291bnRzL3BrZy9nZW4vc2Fhcy9hY2NvdW50cy92MTthY2NvdW50c3YxogIDU0FYqgIQU2Fhcy5BY2NvdW50cy5WMcoCEFNhYXNcQWNjb3VudHNcVjHiAhxTYWFzXEFjY291bnRzXFYxXEdQQk1ldGFkYXRh6gISU2Fhczo6QWNjb3VudHM6OlYxYgZwcm90bzM", [file_buf_validate_validate, file_google_api_annotations, file_google_protobuf_empty, file_host_installation_v1_installation, file_google_protobuf_timestamp, file_saas_accounts_v1_authorization, file_saas_policy_v1_options]); + fileDesc("CiRzYWFzL2FjY291bnRzL3YxL2luc3RhbGxhdGlvbnMucHJvdG8SEHNhYXMuYWNjb3VudHMudjEitwQKDEluc3RhbGxhdGlvbhIUCgJpZBgBIAEoCUIIukgFcgOwAQESGAoGb3JnX2lkGAIgASgJQgi6SAVyA7ABARIkChJhZ2VudF9wcmluY2lwYWxfaWQYAyABKAlCCLpIBXIDsAEBEiQKEm93bmVyX3ByaW5jaXBhbF9pZBgFIAEoCUIIukgFcgOwAQESHgoWY29fb3duZXJfcHJpbmNpcGFsX2lkcxgGIAMoCRIkChJyb290X3Njb3BlX25vZGVfaWQYByABKAlCCLpIBXIDsAEBEjQKBnN0YXR1cxgIIAEoDjIkLnNhYXMuYWNjb3VudHMudjEuSW5zdGFsbGF0aW9uU3RhdHVzEi4KCmNyZWF0ZWRfYXQYCSABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEjMKCnJldm9rZWRfYXQYCiABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wSACIAQESHgoJdGFyZ2V0X2lkGAsgASgJQgu6SAjYAQFyA7ABARIgCg5yZXZva2VkX3JlYXNvbhgMIAEoCUIIukgFcgMYgAQSIAoOZmFpbHVyZV9yZWFzb24YDSABKAlCCLpIBXIDGIAEEhAKCHJldmlzaW9uGA4gASgEEhMKC3NldHVwX3N0YXRlGA8gASgJEhUKDXNldHVwX21lc3NhZ2UYECABKAlCDQoLX3Jldm9rZWRfYXRKBAgEEAVSE3NvbHV0aW9uX2lkZW50aWZpZXIinAQKFkluc3RhbGxTb2x1dGlvblJlcXVlc3QSGAoGb3JnX2lkGAEgASgJQgi6SAVyA7ABARIkChBhZ2VudF9pZGVudGlmaWVyGAIgASgJQgq6SAdyBRABGIAEEh4KDGRpc3BsYXlfbmFtZRgEIAEoCUIIukgFcgMYgAQSIgoQcm9vdF9zY29wZV9sYWJlbBgGIAEoCUIIukgFcgMYgAQSHAoHcm9sZV9pZBgHIAEoCUILukgI2AEBcgOwAQESJwoSb3duZXJfcHJpbmNpcGFsX2lkGAggASgJQgu6SAjYAQFyA7ABARIvChZjb19vd25lcl9wcmluY2lwYWxfaWRzGAkgAygJQg+6SAySAQkQICIFcgOwAQESKgoRYWxsb3dlZF9hdWRpZW5jZXMYCiADKAlCD7pIDJIBCRBAIgVyAxiABBInCg5hbGxvd2VkX3Njb3BlcxgLIAMoCUIPukgMkgEJEEAiBXIDGIABEhsKCXRhcmdldF9pZBgMIAEoCUIIukgFcgOwAQESHwoFc2NvcGUYDSABKAlCC7pICHIGEAIogIBASACIAQESNwoIYXBwcm92YWwYDiABKAsyJS5ob3N0Lmluc3RhbGxhdGlvbi52MS5Db25zZW50QXBwcm92YWxCCAoGX3Njb3BlSgQIAxAESgQIBRAGUhNzb2x1dGlvbl9pZGVudGlmaWVyUg9yb290X3Njb3BlX3BhdGgidAoZTmFycm93SW5zdGFsbGF0aW9uUmVxdWVzdBIYCgZvcmdfaWQYASABKAlCCLpIBXIDsAEBEiEKD2luc3RhbGxhdGlvbl9pZBgCIAEoCUIIukgFcgOwAQESGgoFc2NvcGUYAyABKAlCC7pICHIGEAIogIBAIlcKGFJldHJ5SW5zdGFsbGF0aW9uUmVxdWVzdBIYCgZvcmdfaWQYASABKAlCCLpIBXIDsAEBEiEKD2luc3RhbGxhdGlvbl9pZBgCIAEoCUIIukgFcgOwAQEiVwoYVW5pbnN0YWxsU29sdXRpb25SZXF1ZXN0EhgKBm9yZ19pZBgBIAEoCUIIukgFcgOwAQESIQoPaW5zdGFsbGF0aW9uX2lkGAIgASgJQgi6SAVyA7ABASK+AQokVHJhbnNmZXJJbnN0YWxsYXRpb25Pd25lcnNoaXBSZXF1ZXN0EhgKBm9yZ19pZBgBIAEoCUIIukgFcgOwAQESIQoPaW5zdGFsbGF0aW9uX2lkGAIgASgJQgi6SAVyA7ABARIoChZuZXdfb3duZXJfcHJpbmNpcGFsX2lkGAMgASgJQgi6SAVyA7ABARIvChZjb19vd25lcl9wcmluY2lwYWxfaWRzGAQgAygJQg+6SAySAQkQICIFcgOwAQEiVQoWR2V0SW5zdGFsbGF0aW9uUmVxdWVzdBIYCgZvcmdfaWQYASABKAlCCLpIBXIDsAEBEiEKD2luc3RhbGxhdGlvbl9pZBgCIAEoCUIIukgFcgOwAQEihQEKF0dldEluc3RhbGxhdGlvblJlc3BvbnNlEjQKDGluc3RhbGxhdGlvbhgBIAEoCzIeLnNhYXMuYWNjb3VudHMudjEuSW5zdGFsbGF0aW9uEjQKBmhlYWx0aBgCIAEoDjIkLnNhYXMuYWNjb3VudHMudjEuSW5zdGFsbGF0aW9uSGVhbHRoIp0BChhMaXN0SW5zdGFsbGF0aW9uc1JlcXVlc3QSGAoGb3JnX2lkGAEgASgJQgi6SAVyA7ABARI0CgZzdGF0dXMYAiABKA4yJC5zYWFzLmFjY291bnRzLnYxLkluc3RhbGxhdGlvblN0YXR1cxIdCglwYWdlX3NpemUYAyABKAVCCrpIBxoFGPQDKAASEgoKcGFnZV90b2tlbhgEIAEoCSKBAQoTSW5zdGFsbGF0aW9uU3VtbWFyeRI0CgxpbnN0YWxsYXRpb24YASABKAsyHi5zYWFzLmFjY291bnRzLnYxLkluc3RhbGxhdGlvbhI0CgZoZWFsdGgYAiABKA4yJC5zYWFzLmFjY291bnRzLnYxLkluc3RhbGxhdGlvbkhlYWx0aCJyChlMaXN0SW5zdGFsbGF0aW9uc1Jlc3BvbnNlEjwKDWluc3RhbGxhdGlvbnMYASADKAsyJS5zYWFzLmFjY291bnRzLnYxLkluc3RhbGxhdGlvblN1bW1hcnkSFwoPbmV4dF9wYWdlX3Rva2VuGAIgASgJIq0CChdSZXZpZXdlZFNvbHV0aW9uSW5zdGFsbBIOCgZvcmdfaWQYASABKAkSEAoIYWN0b3JfaWQYAiABKAkSEQoJdGFyZ2V0X2lkGAMgASgJEg4KBnRhcmdldBgEIAEoCRISCgpiaW5kaW5nX2lkGAUgASgJEg8KB3JlbGVhc2UYBiABKAkSFwoPZGVsaXZlcnlfZGlnZXN0GAcgASgJEjcKCGFwcHJvdmFsGAggASgLMiUuaG9zdC5pbnN0YWxsYXRpb24udjEuQ29uc2VudEFwcHJvdmFsEhkKEWluc3RhbGxhdGlvbl9yb2xlGAkgASgJEjsKB21vZHVsZXMYCiADKAsyKi5zYWFzLmFjY291bnRzLnYxLlJldmlld2VkU3VwcG9ydGluZ01vZHVsZSKiAgoYUmV2aWV3ZWRTdXBwb3J0aW5nTW9kdWxlEhEKCXByaW5jaXBhbBgBIAEoCRIRCgl0YXJnZXRfaWQYAiABKAkSDgoGdGFyZ2V0GAMgASgJEhIKCmJpbmRpbmdfaWQYBCABKAkSDwoHcmVsZWFzZRgFIAEoCRIXCg9kZWxpdmVyeV9kaWdlc3QYBiABKAkSGQoRaW5zdGFsbGF0aW9uX3JvbGUYByABKAkSHQoVcmV1c2VfaW5zdGFsbGF0aW9uX2lkGAggASgJEg0KBXNjb3BlGAkgASgJEjcKCGFwcHJvdmFsGAogASgLMiUuaG9zdC5pbnN0YWxsYXRpb24udjEuQ29uc2VudEFwcHJvdmFsEhAKCHJlcXVpcmVkGAsgASgIIlUKHFJldmlld1NvbHV0aW9uSW5zdGFsbFJlcXVlc3QSGAoGb3JnX2lkGAEgASgJQgi6SAVyA7ABARIbCgl0YXJnZXRfaWQYAiABKAlCCLpIBXIDsAEBIn0KHkluc3RhbGxSZXZpZXdlZFNvbHV0aW9uUmVxdWVzdBIYCgZvcmdfaWQYASABKAlCCLpIBXIDsAEBEkEKBnJldmlldxgCIAEoCzIpLnNhYXMuYWNjb3VudHMudjEuUmV2aWV3ZWRTb2x1dGlvbkluc3RhbGxCBrpIA8gBASJzCiRMaXN0T3JnYW5pemF0aW9uSW5zdGFsbGF0aW9uc1JlcXVlc3QSGAoGb3JnX2lkGAEgASgJQgi6SAVyA7ABARIdCglwYWdlX3NpemUYAiABKAVCCrpIBxoFGPQDKAASEgoKcGFnZV90b2tlbhgDIAEoCSLIAwoYT3JnYW5pemF0aW9uSW5zdGFsbGF0aW9uEjQKDGluc3RhbGxhdGlvbhgBIAEoCzIeLnNhYXMuYWNjb3VudHMudjEuSW5zdGFsbGF0aW9uEjQKBmhlYWx0aBgCIAEoDjIkLnNhYXMuYWNjb3VudHMudjEuSW5zdGFsbGF0aW9uSGVhbHRoEjMKBnRhcmdldBgDIAEoCzIjLnNhYXMuYWNjb3VudHMudjEuQXZhaWxhYmxlU29sdXRpb24SFwoPcm9vdF9zY29wZV9wYXRoGAQgASgJEjwKDWdyYW50ZWRfdGVhbXMYBSADKAsyJS5zYWFzLmFjY291bnRzLnYxLkNvbGxlY3Rpb25SZWFkR3JhbnQSPgoPaW5oZXJpdGVkX3RlYW1zGAYgAygLMiUuc2Fhcy5hY2NvdW50cy52MS5Db2xsZWN0aW9uUmVhZEdyYW50EjoKCXJlYWRpbmVzcxgHIAEoCzInLnNhYXMuYWNjb3VudHMudjEuSW5zdGFsbGF0aW9uUmVhZGluZXNzEjgKD3JlYWNoZWRfbW9kdWxlcxgIIAMoCzIfLnNhYXMuYWNjb3VudHMudjEuUmVhY2hlZE1vZHVsZSLtAQoSUmVhZGluZXNzQ29uZGl0aW9uEjYKBGtpbmQYASABKA4yKC5zYWFzLmFjY291bnRzLnYxLlJlYWRpbmVzc0NvbmRpdGlvbktpbmQSDwoHc3ViamVjdBgCIAEoCRIOCgZkZXRhaWwYAyABKAkSOAoKY2xlYXJlZF9ieRgEIAEoDjIkLnNhYXMuYWNjb3VudHMudjEuUmVhZGluZXNzQ2xlYXJlZEJ5EjEKBmFjdGlvbhgFIAEoDjIhLnNhYXMuYWNjb3VudHMudjEuUmVhZGluZXNzQWN0aW9uEhEKCXNvdXJjZV9pZBgGIAEoCSJbChVJbnN0YWxsYXRpb25SZWFkaW5lc3MSDQoFcmVhZHkYASABKAgSMwoFdW5tZXQYAiADKAsyJC5zYWFzLmFjY291bnRzLnYxLlJlYWRpbmVzc0NvbmRpdGlvbiJPCg1SZWFjaGVkTW9kdWxlEhAKCGF1ZGllbmNlGAEgASgJEhcKD2luc3RhbGxhdGlvbl9pZBgCIAEoCRITCgtyb3V0ZV9hbGlhcxgDIAEoCSKDAQolTGlzdE9yZ2FuaXphdGlvbkluc3RhbGxhdGlvbnNSZXNwb25zZRJBCg1pbnN0YWxsYXRpb25zGAEgAygLMiouc2Fhcy5hY2NvdW50cy52MS5Pcmdhbml6YXRpb25JbnN0YWxsYXRpb24SFwoPbmV4dF9wYWdlX3Rva2VuGAIgASgJIpMBCh5HcmFudEluc3RhbGxhdGlvblRvVGVhbVJlcXVlc3QSGAoGb3JnX2lkGAEgASgJQgi6SAVyA7ABARIhCg9pbnN0YWxsYXRpb25faWQYAiABKAlCCLpIBXIDsAEBEhkKB3RlYW1faWQYAyABKAlCCLpIBXIDsAEBEhkKB3JvbGVfaWQYBCABKAlCCLpIBXIDsAEBInUKE0NhcnJpZWRNb2R1bGVBY2Nlc3MSEAoIYXVkaWVuY2UYASABKAkSFwoPaW5zdGFsbGF0aW9uX2lkGAIgASgJEhIKCnNjb3BlX3BhdGgYAyABKAkSDwoHcm9sZV9pZBgEIAEoCRIOCgZzY29wZXMYBSADKAkiuwEKH0dyYW50SW5zdGFsbGF0aW9uVG9UZWFtUmVzcG9uc2USKwoFZ3JhbnQYASABKAsyHC5zYWFzLmFjY291bnRzLnYxLlNjb3BlR3JhbnQSNgoHY2FycmllZBgCIAMoCzIlLnNhYXMuYWNjb3VudHMudjEuQ2FycmllZE1vZHVsZUFjY2VzcxIzCgV1bm1ldBgDIAMoCzIkLnNhYXMuYWNjb3VudHMudjEuUmVhZGluZXNzQ29uZGl0aW9uIpYBCiFSZXZva2VJbnN0YWxsYXRpb25Gcm9tVGVhbVJlcXVlc3QSGAoGb3JnX2lkGAEgASgJQgi6SAVyA7ABARIhCg9pbnN0YWxsYXRpb25faWQYAiABKAlCCLpIBXIDsAEBEhkKB3RlYW1faWQYAyABKAlCCLpIBXIDsAEBEhkKB3JvbGVfaWQYBCABKAlCCLpIBXIDsAEBIoQCChFBdmFpbGFibGVTb2x1dGlvbhIbCgl0YXJnZXRfaWQYASABKAlCCLpIBXIDsAEBEhIKCmJpbmRpbmdfaWQYAiABKAkSEwoLcm91dGVfYWxpYXMYAyABKAkSGQoRcmVsZWFzZV9wdWJsaXNoZXIYBCABKAkSFAoMcmVsZWFzZV9uYW1lGAUgASgJEhcKD3JlbGVhc2VfdmVyc2lvbhgGIAEoCRIZChFvcGVuZWRfZ2VuZXJhdGlvbhgHIAEoBBIaChJhcHBsaWVkX2dlbmVyYXRpb24YCCABKAQSEQoJaW5zdGFsbGVkGAkgASgIEhUKBGtpbmQYCiABKAlCB7pIBHICGCAibAodTGlzdEF2YWlsYWJsZVNvbHV0aW9uc1JlcXVlc3QSGAoGb3JnX2lkGAEgASgJQgi6SAVyA7ABARIdCglwYWdlX3NpemUYAiABKAVCCrpIBxoFGPQDKAASEgoKcGFnZV90b2tlbhgDIAEoCSJxCh5MaXN0QXZhaWxhYmxlU29sdXRpb25zUmVzcG9uc2USNgoJc29sdXRpb25zGAEgAygLMiMuc2Fhcy5hY2NvdW50cy52MS5BdmFpbGFibGVTb2x1dGlvbhIXCg9uZXh0X3BhZ2VfdG9rZW4YAiABKAkquwEKEkluc3RhbGxhdGlvblN0YXR1cxIjCh9JTlNUQUxMQVRJT05fU1RBVFVTX1VOU1BFQ0lGSUVEEAASHgoaSU5TVEFMTEFUSU9OX1NUQVRVU19BQ1RJVkUQARIfChtJTlNUQUxMQVRJT05fU1RBVFVTX1JFVk9LRUQQAhIfChtJTlNUQUxMQVRJT05fU1RBVFVTX1BFTkRJTkcQAxIeChpJTlNUQUxMQVRJT05fU1RBVFVTX0ZBSUxFRBAEKoQCChJJbnN0YWxsYXRpb25IZWFsdGgSIwofSU5TVEFMTEFUSU9OX0hFQUxUSF9VTlNQRUNJRklFRBAAEh8KG0lOU1RBTExBVElPTl9IRUFMVEhfSEVBTFRIWRABEikKJUlOU1RBTExBVElPTl9IRUFMVEhfTk9fRUxJR0lCTEVfT1dORVIQAhIlCiFJTlNUQUxMQVRJT05fSEVBTFRIX0FHRU5UX1JFVk9LRUQQAxImCiJJTlNUQUxMQVRJT05fSEVBTFRIX0FHRU5UX0RJU0FCTEVEEAQSLgoqSU5TVEFMTEFUSU9OX0hFQUxUSF9TVEFORElOR19HUkFOVF9NSVNTSU5HEAUq8wMKFlJlYWRpbmVzc0NvbmRpdGlvbktpbmQSKAokUkVBRElORVNTX0NPTkRJVElPTl9LSU5EX1VOU1BFQ0lGSUVEEAASMQotUkVBRElORVNTX0NPTkRJVElPTl9LSU5EX0lOU1RBTExfU1RFUF9QRU5ESU5HEAESMAosUkVBRElORVNTX0NPTkRJVElPTl9LSU5EX0lOU1RBTExfU1RFUF9GQUlMRUQQAhItCilSRUFESU5FU1NfQ09ORElUSU9OX0tJTkRfUkVDT1JEX1VOSEVBTFRIWRADEjUKMVJFQURJTkVTU19DT05ESVRJT05fS0lORF9NT0RVTEVfUFJJTkNJUEFMX09NSVRURUQQBBI2CjJSRUFESU5FU1NfQ09ORElUSU9OX0tJTkRfT1BFUkFUSU9OX0JJTkRJTkdfT01JVFRFRBAFEjEKLVJFQURJTkVTU19DT05ESVRJT05fS0lORF9TT1VSQ0VfTk9UX0RFTEVHQVRFRBAGEjkKNVJFQURJTkVTU19DT05ESVRJT05fS0lORF9SRUFDSEVEX01PRFVMRV9OT1RfSU5TVEFMTEVEEAcSPgo6UkVBRElORVNTX0NPTkRJVElPTl9LSU5EX0NBUlJJRURfTU9EVUxFX0FDQ0VTU19OT1RfREVSSVZFRBAIKtUBChJSZWFkaW5lc3NDbGVhcmVkQnkSJAogUkVBRElORVNTX0NMRUFSRURfQllfVU5TUEVDSUZJRUQQABIfChtSRUFESU5FU1NfQ0xFQVJFRF9CWV9WSUVXRVIQARIrCidSRUFESU5FU1NfQ0xFQVJFRF9CWV9PUkdBTklaQVRJT05fQURNSU4QAhIqCiZSRUFESU5FU1NfQ0xFQVJFRF9CWV9QTEFURk9STV9PUEVSQVRPUhADEh8KG1JFQURJTkVTU19DTEVBUkVEX0JZX05PQk9EWRAEKp8CCg9SZWFkaW5lc3NBY3Rpb24SIAocUkVBRElORVNTX0FDVElPTl9VTlNQRUNJRklFRBAAEikKJVJFQURJTkVTU19BQ1RJT05fTk9ORV9BVF9USElTX1NVUkZBQ0UQARInCiNSRUFESU5FU1NfQUNUSU9OX1JFVFJZX0lOU1RBTExfU1RFUBACEiUKIVJFQURJTkVTU19BQ1RJT05fUkVDT05ORUNUX1NPVVJDRRADEicKI1JFQURJTkVTU19BQ1RJT05fVFJBTlNGRVJfT1dORVJTSElQEAQSKwonUkVBRElORVNTX0FDVElPTl9JTlNUQUxMX1JFQUNIRURfTU9EVUxFEAUSGQoVUkVBRElORVNTX0FDVElPTl9XQUlUEAYyrhUKE0luc3RhbGxhdGlvblNlcnZpY2USxgEKFVJldmlld1NvbHV0aW9uSW5zdGFsbBIuLnNhYXMuYWNjb3VudHMudjEuUmV2aWV3U29sdXRpb25JbnN0YWxsUmVxdWVzdBopLnNhYXMuYWNjb3VudHMudjEuUmV2aWV3ZWRTb2x1dGlvbkluc3RhbGwiUsLzGCIIAhAEKgwKBm9yZ19pZBACGAEwAToCEAFAAUgDUANYA2ABgtPkkwImOgEqIiEvdjEvc29sdXRpb24taW5zdGFsbGF0aW9ucy9yZXZpZXcSuAEKF0luc3RhbGxSZXZpZXdlZFNvbHV0aW9uEjAuc2Fhcy5hY2NvdW50cy52MS5JbnN0YWxsUmV2aWV3ZWRTb2x1dGlvblJlcXVlc3QaHi5zYWFzLmFjY291bnRzLnYxLkluc3RhbGxhdGlvbiJLwvMYIggCEAQqDAoGb3JnX2lkEAIYATABOgIQAUABSARQA1gDYAGC0+STAh86ASoiGi92MS9zb2x1dGlvbi1pbnN0YWxsYXRpb25zEroBCg9JbnN0YWxsU29sdXRpb24SKC5zYWFzLmFjY291bnRzLnYxLkluc3RhbGxTb2x1dGlvblJlcXVlc3QaHi5zYWFzLmFjY291bnRzLnYxLkluc3RhbGxhdGlvbiJdwvMYPQgCEAQqDAoGb3JnX2lkEAIYATABOh0KGXNhYXMuaW5zdGFsbGF0aW9uLmNyZWF0ZWQQAkABSARQA1gDYAGC0+STAhY6ASoiES92MS9pbnN0YWxsYXRpb25zEtIBChFVbmluc3RhbGxTb2x1dGlvbhIqLnNhYXMuYWNjb3VudHMudjEuVW5pbnN0YWxsU29sdXRpb25SZXF1ZXN0GhYuZ29vZ2xlLnByb3RvYnVmLkVtcHR5InnC8xg9CAIQBCoMCgZvcmdfaWQQAhgBMAE6HQoZc2Fhcy5pbnN0YWxsYXRpb24ucmV2b2tlZBACQAFIBFADWANgAYLT5JMCMjoBKiItL3YxL2luc3RhbGxhdGlvbnMve2luc3RhbGxhdGlvbl9pZH06dW5pbnN0YWxsEtIBChJOYXJyb3dJbnN0YWxsYXRpb24SKy5zYWFzLmFjY291bnRzLnYxLk5hcnJvd0luc3RhbGxhdGlvblJlcXVlc3QaFi5nb29nbGUucHJvdG9idWYuRW1wdHkid8LzGD4IAhAEKgwKBm9yZ19pZBACGAEwAToeChpzYWFzLmluc3RhbGxhdGlvbi5uYXJyb3dlZBACQAFIBFADWANgAYLT5JMCLzoBKiIqL3YxL2luc3RhbGxhdGlvbnMve2luc3RhbGxhdGlvbl9pZH06bmFycm93EtYBChFSZXRyeUluc3RhbGxhdGlvbhIqLnNhYXMuYWNjb3VudHMudjEuUmV0cnlJbnN0YWxsYXRpb25SZXF1ZXN0Gh4uc2Fhcy5hY2NvdW50cy52MS5JbnN0YWxsYXRpb24idcLzGD0IAhAEKgwKBm9yZ19pZBACGAEwATodChlzYWFzLmluc3RhbGxhdGlvbi5yZXRyaWVkEAJAAUgEUANYA2ABgtPkkwIuOgEqIikvdjEvaW5zdGFsbGF0aW9ucy97aW5zdGFsbGF0aW9uX2lkfTpyZXRyeRKJAgodVHJhbnNmZXJJbnN0YWxsYXRpb25Pd25lcnNoaXASNi5zYWFzLmFjY291bnRzLnYxLlRyYW5zZmVySW5zdGFsbGF0aW9uT3duZXJzaGlwUmVxdWVzdBoeLnNhYXMuYWNjb3VudHMudjEuSW5zdGFsbGF0aW9uIo8BwvMYSwgCEAQqDAoGb3JnX2lkEAIYATABOisKJ3NhYXMuaW5zdGFsbGF0aW9uLm93bmVyc2hpcF90cmFuc2ZlcnJlZBACQAFIBFADWANgAYLT5JMCOjoBKiI1L3YxL2luc3RhbGxhdGlvbnMve2luc3RhbGxhdGlvbl9pZH06dHJhbnNmZXJPd25lcnNoaXASuQEKD0dldEluc3RhbGxhdGlvbhIoLnNhYXMuYWNjb3VudHMudjEuR2V0SW5zdGFsbGF0aW9uUmVxdWVzdBopLnNhYXMuYWNjb3VudHMudjEuR2V0SW5zdGFsbGF0aW9uUmVzcG9uc2UiUcLzGCIIAhADKgwKBm9yZ19pZBACGAEwAToCEAFAAUgDUANYA2ABgtPkkwIlEiMvdjEvaW5zdGFsbGF0aW9ucy97aW5zdGFsbGF0aW9uX2lkfRKGAQoRTGlzdEluc3RhbGxhdGlvbnMSKi5zYWFzLmFjY291bnRzLnYxLkxpc3RJbnN0YWxsYXRpb25zUmVxdWVzdBorLnNhYXMuYWNjb3VudHMudjEuTGlzdEluc3RhbGxhdGlvbnNSZXNwb25zZSIYwvMYFAgDEAEwAToCEAFAAUgHUANYA2ABEsoBChZMaXN0QXZhaWxhYmxlU29sdXRpb25zEi8uc2Fhcy5hY2NvdW50cy52MS5MaXN0QXZhaWxhYmxlU29sdXRpb25zUmVxdWVzdBowLnNhYXMuYWNjb3VudHMudjEuTGlzdEF2YWlsYWJsZVNvbHV0aW9uc1Jlc3BvbnNlIk3C8xgiCAIQBCoMCgZvcmdfaWQQAhgBMAE6AhABQAFIA1ADWANgAYLT5JMCIRIfL3YxL2luc3RhbGxhdGlvbnM6bGlzdEF2YWlsYWJsZRLRAQodTGlzdE9yZ2FuaXphdGlvbkluc3RhbGxhdGlvbnMSNi5zYWFzLmFjY291bnRzLnYxLkxpc3RPcmdhbml6YXRpb25JbnN0YWxsYXRpb25zUmVxdWVzdBo3LnNhYXMuYWNjb3VudHMudjEuTGlzdE9yZ2FuaXphdGlvbkluc3RhbGxhdGlvbnNSZXNwb25zZSI/wvMYIggCEAQqDAoGb3JnX2lkEAIYATABOgIQAUABSANQA1gDYAGC0+STAhMSES92MS9pbnN0YWxsYXRpb25zEvABChdHcmFudEluc3RhbGxhdGlvblRvVGVhbRIwLnNhYXMuYWNjb3VudHMudjEuR3JhbnRJbnN0YWxsYXRpb25Ub1RlYW1SZXF1ZXN0GjEuc2Fhcy5hY2NvdW50cy52MS5HcmFudEluc3RhbGxhdGlvblRvVGVhbVJlc3BvbnNlInDC8xg4CAIQBCoMCgZvcmdfaWQQAhgBMAE6FgoSc2Fhcy5zY29wZS5ncmFudGVkEAJAAUgEUANYA2ABeAKC0+STAi46ASoiKS92MS9pbnN0YWxsYXRpb25zL3tpbnN0YWxsYXRpb25faWR9L3RlYW1zEu0BChpSZXZva2VJbnN0YWxsYXRpb25Gcm9tVGVhbRIzLnNhYXMuYWNjb3VudHMudjEuUmV2b2tlSW5zdGFsbGF0aW9uRnJvbVRlYW1SZXF1ZXN0GhYuZ29vZ2xlLnByb3RvYnVmLkVtcHR5IoEBwvMYOAgCEAQqDAoGb3JnX2lkEAIYATABOhYKEnNhYXMuc2NvcGUucmV2b2tlZBACQAFIBFADWANgAXgCgtPkkwI/OgEqIjovdjEvaW5zdGFsbGF0aW9ucy97aW5zdGFsbGF0aW9uX2lkfS90ZWFtcy97dGVhbV9pZH06cmV2b2tlQroBChRjb20uc2Fhcy5hY2NvdW50cy52MUISSW5zdGFsbGF0aW9uc1Byb3RvUAFaLGFjY291bnRzL3BrZy9nZW4vc2Fhcy9hY2NvdW50cy92MTthY2NvdW50c3YxogIDU0FYqgIQU2Fhcy5BY2NvdW50cy5WMcoCEFNhYXNcQWNjb3VudHNcVjHiAhxTYWFzXEFjY291bnRzXFYxXEdQQk1ldGFkYXRh6gISU2Fhczo6QWNjb3VudHM6OlYxYgZwcm90bzM", [file_buf_validate_validate, file_google_api_annotations, file_google_protobuf_empty, file_host_installation_v1_installation, file_google_protobuf_timestamp, file_saas_accounts_v1_authorization, file_saas_policy_v1_options]); /** * Installation is one solution's bound identity in an org. Server-assigned ids; @@ -177,7 +177,10 @@ export type InstallSolutionRequest = Message<"saas.accounts.v1.InstallSolutionRe /** * The least-privilege role granted to the agent at the root node. This is * deliberately NOT the full ":catalog" role; widening is a separate, - * audited admin action. + * audited admin action. Empty when the delivered module declares its own + * installation role: the host selects a named one, and "none" grants the + * agent no standing role at all. A role given for "none" is refused, and an + * empty one is refused for a target that declares no installation role. * * @generated from field: string role_id = 7; */ diff --git a/module/services/frontend/code/ui-inventory.json b/module/services/frontend/code/ui-inventory.json index 0502a36aa..7de9cc9cd 100644 --- a/module/services/frontend/code/ui-inventory.json +++ b/module/services/frontend/code/ui-inventory.json @@ -5131,6 +5131,40 @@ "coverage": "missing", "remaining": "Owner stories or a reviewed nonvisual exclusion still required." }, + { + "source": "src/features/solution-installations/ui/declared-access.tsx", + "imports": [ + "@/gen/host/installation/v1/installation_pb", + "../model/install-review" + ], + "owner": "SaaS application", + "disposition": "keep feature-local", + "reason": "States an installation's declared machine access and call ceiling from the authority's offer, once, for both the solution review and the module page review.", + "stories": [], + "coverage": "missing", + "remaining": "Owner stories or a reviewed nonvisual exclusion still required." + }, + { + "source": "src/features/solution-installations/ui/install-module-page-dialog.tsx", + "imports": [ + "@connectrpc/connect", + "@tanstack/react-query", + "react", + "@/gen/host/installation/v1/installation_pb", + "@/gen/saas/accounts/v1/installations_pb", + "@/lib/connect/transport", + "@/shared/ui", + "../model/install-review", + "../model/installations", + "./declared-access" + ], + "owner": "SaaS application", + "disposition": "keep feature-local", + "reason": "Reviews a module page that declares roles from the authority's offer and installs it with no agent role; its roles take teams in Setup. Lower-level presentation consumes the shared kit.", + "stories": [], + "coverage": "missing", + "remaining": "Owner stories or a reviewed nonvisual exclusion still required." + }, { "source": "src/features/solution-installations/ui/install-module-dialog.tsx", "imports": [