diff --git a/module/contracts/api/accounts/authority/contract.binpb b/module/contracts/api/accounts/authority/contract.binpb index 590c0a8eb..db3861fdb 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/datasource.proto b/module/contracts/api/accounts/authority/proto/saas/accounts/v1/datasource.proto index 8a75033fc..dac135132 100644 --- a/module/contracts/api/accounts/authority/proto/saas/accounts/v1/datasource.proto +++ b/module/contracts/api/accounts/authority/proto/saas/accounts/v1/datasource.proto @@ -189,7 +189,48 @@ message UploadDatasourceConfig { // AWS-style access key id. The matching secret access key is the credential. string access_key_id = 5; // Upper bound on objects fetched per sync. Zero applies the connector default. + // Only objects that pass the filters below count against it. uint32 max_objects = 6; + // Which keys under the prefix are ingested. Each pattern is an RE2 regular + // expression matched against the object key with the prefix removed, + // unanchored and case-sensitive (anchor with ^ and $, fold case with (?i)). + // An empty include admits every key; an exclude match always wins. Applied + // when the prefix is listed, before anything is fetched, so an excluded + // object is never read and never counts against max_objects. An invalid + // expression is refused when the source is added or updated, never at sync: + // the call fails INVALID_ARGUMENT with a google.rpc.BadRequest violation, and + // a message that starts with the field's path, e.g. + // "upload.include_patterns[1]: …". + repeated string include_patterns = 7 [(buf.validate.field).repeated = { + max_items: 16 + items: { + string: { + min_len: 1 + max_len: 256 + } + } + }]; + repeated string exclude_patterns = 8 [(buf.validate.field).repeated = { + max_items: 16 + items: { + string: { + min_len: 1 + max_len: 256 + } + } + }]; + // Case-insensitive key suffixes, e.g. [".md", ".pdf"], intersected with the + // patterns. Empty admits every type. GitHub's file_extensions semantics. + repeated string file_extensions = 9 [(buf.validate.field).repeated = { + max_items: 32 + items: { + string: { + min_len: 2 + max_len: 33 + pattern: "^\\.[A-Za-z0-9][A-Za-z0-9._-]*$" + } + } + }]; } // Datasource is one connected external datasource. It is org-scoped and carries @@ -437,6 +478,27 @@ message UpdateSourceRequest { // sole reason that the source had reconciled since the form was opened. What // an editor needs guarded is what they were looking at. SourceFetchScope expected = 6 [(buf.validate.field).required = true]; + // An object-storage source's key patterns (see UploadDatasourceConfig), + // replaced whole like every field here. Its file_extensions is field 5; its + // branch and paths must be empty. Refused for a github source. + repeated string include_patterns = 7 [(buf.validate.field).repeated = { + max_items: 16 + items: { + string: { + min_len: 1 + max_len: 256 + } + } + }]; + repeated string exclude_patterns = 8 [(buf.validate.field).repeated = { + max_items: 16 + items: { + string: { + min_len: 1 + max_len: 256 + } + } + }]; } // SourceFetchScope is what a source fetches — the three values UpdateSource @@ -467,6 +529,17 @@ message SourceFetchScope { max_len: 33 } } + }]; repeated string include_patterns = 4 [(buf.validate.field).repeated = { + max_items: 16 + items: { + string: {max_len: 256} + } + }]; + repeated string exclude_patterns = 5 [(buf.validate.field).repeated = { + max_items: 16 + items: { + string: {max_len: 256} + } }]; } diff --git a/module/contracts/api/accounts/connect/contract.binpb b/module/contracts/api/accounts/connect/contract.binpb index 590c0a8eb..db3861fdb 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/datasource.proto b/module/contracts/api/accounts/connect/proto/saas/accounts/v1/datasource.proto index 8a75033fc..dac135132 100644 --- a/module/contracts/api/accounts/connect/proto/saas/accounts/v1/datasource.proto +++ b/module/contracts/api/accounts/connect/proto/saas/accounts/v1/datasource.proto @@ -189,7 +189,48 @@ message UploadDatasourceConfig { // AWS-style access key id. The matching secret access key is the credential. string access_key_id = 5; // Upper bound on objects fetched per sync. Zero applies the connector default. + // Only objects that pass the filters below count against it. uint32 max_objects = 6; + // Which keys under the prefix are ingested. Each pattern is an RE2 regular + // expression matched against the object key with the prefix removed, + // unanchored and case-sensitive (anchor with ^ and $, fold case with (?i)). + // An empty include admits every key; an exclude match always wins. Applied + // when the prefix is listed, before anything is fetched, so an excluded + // object is never read and never counts against max_objects. An invalid + // expression is refused when the source is added or updated, never at sync: + // the call fails INVALID_ARGUMENT with a google.rpc.BadRequest violation, and + // a message that starts with the field's path, e.g. + // "upload.include_patterns[1]: …". + repeated string include_patterns = 7 [(buf.validate.field).repeated = { + max_items: 16 + items: { + string: { + min_len: 1 + max_len: 256 + } + } + }]; + repeated string exclude_patterns = 8 [(buf.validate.field).repeated = { + max_items: 16 + items: { + string: { + min_len: 1 + max_len: 256 + } + } + }]; + // Case-insensitive key suffixes, e.g. [".md", ".pdf"], intersected with the + // patterns. Empty admits every type. GitHub's file_extensions semantics. + repeated string file_extensions = 9 [(buf.validate.field).repeated = { + max_items: 32 + items: { + string: { + min_len: 2 + max_len: 33 + pattern: "^\\.[A-Za-z0-9][A-Za-z0-9._-]*$" + } + } + }]; } // Datasource is one connected external datasource. It is org-scoped and carries @@ -437,6 +478,27 @@ message UpdateSourceRequest { // sole reason that the source had reconciled since the form was opened. What // an editor needs guarded is what they were looking at. SourceFetchScope expected = 6 [(buf.validate.field).required = true]; + // An object-storage source's key patterns (see UploadDatasourceConfig), + // replaced whole like every field here. Its file_extensions is field 5; its + // branch and paths must be empty. Refused for a github source. + repeated string include_patterns = 7 [(buf.validate.field).repeated = { + max_items: 16 + items: { + string: { + min_len: 1 + max_len: 256 + } + } + }]; + repeated string exclude_patterns = 8 [(buf.validate.field).repeated = { + max_items: 16 + items: { + string: { + min_len: 1 + max_len: 256 + } + } + }]; } // SourceFetchScope is what a source fetches — the three values UpdateSource @@ -467,6 +529,17 @@ message SourceFetchScope { max_len: 33 } } + }]; repeated string include_patterns = 4 [(buf.validate.field).repeated = { + max_items: 16 + items: { + string: {max_len: 256} + } + }]; + repeated string exclude_patterns = 5 [(buf.validate.field).repeated = { + max_items: 16 + items: { + string: {max_len: 256} + } }]; } diff --git a/module/contracts/api/catalog.codefly.json b/module/contracts/api/catalog.codefly.json index 5a4135c62..ba2fc803f 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:373a8f3a22b7968c36dd9faffb9be5ef64213b1de2a532f5e567007bd65caaa7", + "digest": "sha256:a899ecff5c0e74ea89f34a8c761e58c79b50c0a5d2f5538e8e70c252233a0dbf", "services": [ { "name": "ModuleCapabilitiesService", @@ -65,7 +65,7 @@ "kind": "protobuf", "package": "saas.accounts.v1", "path": "contracts/api/accounts/connect/contract.binpb", - "digest": "sha256:373a8f3a22b7968c36dd9faffb9be5ef64213b1de2a532f5e567007bd65caaa7", + "digest": "sha256:a899ecff5c0e74ea89f34a8c761e58c79b50c0a5d2f5538e8e70c252233a0dbf", "services": [ { "name": "APIKeyService", diff --git a/module/module.package.codefly.yaml b/module/module.package.codefly.yaml index 34ea2ee34..6886e4f17 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:373a8f3a22b7968c36dd9faffb9be5ef64213b1de2a532f5e567007bd65caaa7 + digest: sha256:a899ecff5c0e74ea89f34a8c761e58c79b50c0a5d2f5538e8e70c252233a0dbf - endpoint: connect kind: protobuf package: saas.accounts.v1 path: contracts/api/accounts/connect/contract.binpb - digest: sha256:373a8f3a22b7968c36dd9faffb9be5ef64213b1de2a532f5e567007bd65caaa7 + digest: sha256:a899ecff5c0e74ea89f34a8c761e58c79b50c0a5d2f5538e8e70c252233a0dbf - name: auth-gateway endpoints: [grpc, rest] api-contracts: diff --git a/module/services/accounts/AGENTS.md b/module/services/accounts/AGENTS.md index efe99b0ec..8145f0d1f 100644 --- a/module/services/accounts/AGENTS.md +++ b/module/services/accounts/AGENTS.md @@ -1042,6 +1042,42 @@ The transport underneath — h2c rather than server TLS, the resolved origin, an the key set a consumer verifies host-signed credentials against — is [../../INTERNAL_TRANSPORT.md](../../INTERNAL_TRANSPORT.md). +## An object-storage source is a prefix and a key filter + +An `upload` ("Object storage") source reads one bucket under one `prefix`, and +`UploadDatasourceConfig` narrows it further: `include_patterns` and +`exclude_patterns` are RE2 expressions over the object key with the prefix +removed (unanchored, case-sensitive; exclude wins; an empty include admits +everything), and `file_extensions` intersects them with GitHub's suffix +semantics. `uploadKeyScope` (`pkg/business/datasource_file_filter.go`, beside +GitHub's own filter) is the one matcher. The connector applies it, not the +listing source: it is the `InScope` of an `objectstore.Scope` +(`pkg/datasource/objectstore/scope.go`), whose `Window` takes the store's +listing whole and keeps the matches, counting only them against +`max_objects`. So an excluded object is never fetched and never uses up the +cap, the closing listing names only matches, and replacing what lists the +prefix leaves the filter where it is. + +- **Refused at the call, never at sync.** `AddSource` and `UpdateSource` + compile every pattern and refuse an invalid one, more than 16, or one over 256 + characters as `INVALID_ARGUMENT` with a `google.rpc.BadRequest` violation and + a message that leads with the field path (`upload.include_patterns[1]: …` on + add, `include_patterns[1]: …` on update). The kit places a refusal under its + field by that leading path, because the TypeScript SDK carries no + error-detail types. +- **Narrowing deletes.** `UpdateSource` takes the patterns and file types for an + upload source (branch and paths are refused), under the same `expected` rule + as a GitHub edit, and queues the source's pull sync. That sync's closing + listing omits what the narrower filter excludes, and the store tombstones it + — unlike a GitHub snapshot, which only declares scope (below). +- **Access follows the source.** A source is (bucket, prefix, filter) → one + collection → one grant; two sources on one bucket with different filters are + two collections with different readers. The catalog description says so. +- **The filter is the host's whatever the store.** An S3-style listing takes a + prefix and a delimiter only, so the object-storage service needs nothing for + this. When the envelope connector replaces `runUploadSync`, it takes the + same `Scope` (#1211). + ## A datasource source is refreshed by three things, and one of them covers every provider Ranked by latency, a source's content is refreshed by a push (near-live), by a diff --git a/module/services/accounts/code/pkg/adapters/datasource_handler.go b/module/services/accounts/code/pkg/adapters/datasource_handler.go index f37add227..ddf052efb 100644 --- a/module/services/accounts/code/pkg/adapters/datasource_handler.go +++ b/module/services/accounts/code/pkg/adapters/datasource_handler.go @@ -107,6 +107,10 @@ func (h *datasourceConnectHandler) AddSource( Prefix: up.Prefix, AccessKeyID: up.AccessKeyId, MaxObjects: int(up.MaxObjects), + + IncludePatterns: up.IncludePatterns, + ExcludePatterns: up.ExcludePatterns, + FileExtensions: up.FileExtensions, } } source, err := h.svc.AddSource(ctx, actorID, input) @@ -134,6 +138,9 @@ func (h *datasourceConnectHandler) UpdateSource( Branch: req.Msg.Branch, Paths: req.Msg.Paths, FileExtensions: req.Msg.FileExtensions, + + IncludePatterns: req.Msg.IncludePatterns, + ExcludePatterns: req.Msg.ExcludePatterns, } // The scope the caller was shown. The descriptor marks it required, so an // absent one is already refused before this handler runs; it is mapped @@ -144,6 +151,9 @@ func (h *datasourceConnectHandler) UpdateSource( Branch: expected.GetBranch(), Paths: expected.GetPaths(), FileExtensions: expected.GetFileExtensions(), + + IncludePatterns: expected.GetIncludePatterns(), + ExcludePatterns: expected.GetExcludePatterns(), } } source, jobID, err := h.svc.UpdateSource(ctx, actorID, input) @@ -452,6 +462,10 @@ func datasourceSourceToProto(source *business.DatasourceSource, registry *connec Prefix: source.Upload.Prefix, AccessKeyId: source.Upload.AccessKeyID, MaxObjects: uint32(source.Upload.MaxObjects), + + IncludePatterns: source.Upload.IncludePatterns, + ExcludePatterns: source.Upload.ExcludePatterns, + FileExtensions: source.Upload.FileExtensions, } } if source.LastSyncedAt != nil { diff --git a/module/services/accounts/code/pkg/business/audit_registry.go b/module/services/accounts/code/pkg/business/audit_registry.go index 97be734c4..47beb8440 100644 --- a/module/services/accounts/code/pkg/business/audit_registry.go +++ b/module/services/accounts/code/pkg/business/audit_registry.go @@ -846,8 +846,9 @@ var auditEventCatalog = []AuditEventDefinition{ // reads this declaration for. An edit never moves the boundary, but it is // the collection whose contents the next snapshot replaces, so whoever may // read that collection is exactly who needs to see it. - mutation(EventDatasourceSourceUpdated, CategorySystem, "A connected datasource's branch or path filters were changed.", - str("repo"), str("branch"), str("previous_branch"), strs("paths"), strs("file_extensions"), str("boundary")), + mutation(EventDatasourceSourceUpdated, CategorySystem, "A connected datasource's branch, path or key filters were changed.", + str("repo"), str("branch"), str("previous_branch"), strs("paths"), strs("file_extensions"), str("boundary"), + str("bucket"), str("prefix"), strs("include_patterns"), strs("exclude_patterns")), observation(EventDatasourceSyncCompleted, CategorySystem, "A datasource ingestion job completed.", sourceSyncFields...), observation(EventDatasourceSyncFailed, CategorySystem, "A datasource ingestion attempt failed and may retry.", sourceSyncFields...), observation(EventDatasourceChangeSetCompiled, CategorySystem, "A GitHub delivery was compiled into a change set.", diff --git a/module/services/accounts/code/pkg/business/datasource_connectors.go b/module/services/accounts/code/pkg/business/datasource_connectors.go index 2ba1db5fc..164062ea0 100644 --- a/module/services/accounts/code/pkg/business/datasource_connectors.go +++ b/module/services/accounts/code/pkg/business/datasource_connectors.go @@ -71,7 +71,7 @@ func (s *Service) newDatasourceConnectorRegistry() *connector.Registry { })) must(r.RegisterNonConformant(connector.Descriptor{ Key: DatasourceProviderUpload, DisplayName: "Object storage", - Description: "An S3-compatible bucket; objects under a prefix are pulled on sync.", + Description: "An S3-compatible bucket; the objects under a prefix that pass its key filter are pulled on sync. A source is one collection with one set of readers, so a bucket shared by several audiences is several sources.", Interface: connector.InterfaceFiles, CredentialModes: []connector.CredentialMode{connector.CredentialStaticSecret}, Gap: gapUploadDatasource, diff --git a/module/services/accounts/code/pkg/business/datasource_deliveries.go b/module/services/accounts/code/pkg/business/datasource_deliveries.go index 9145e80a5..347f5e955 100644 --- a/module/services/accounts/code/pkg/business/datasource_deliveries.go +++ b/module/services/accounts/code/pkg/business/datasource_deliveries.go @@ -1032,7 +1032,8 @@ func forcedReconcileMode(mode string) bool { // connecting is an explicit act, and a source reconnected after being deleted // must not be dropped as a replay of the first one's schedule. func (s *Service) enqueueFirstPullSync(ctx context.Context, source *DatasourceSource) error { - return s.enqueuePullSync(ctx, source, datasourceSyncRequestSource, NewIDString()) + _, err := s.enqueuePullSync(ctx, source, datasourceSyncRequestSource, NewIDString()) + return err } // enqueueScheduledPullSync is the periodic reconcile for a provider the host @@ -1043,15 +1044,16 @@ func (s *Service) enqueueFirstPullSync(ctx context.Context, source *DatasourceSo // // STOPGAP (issue #1112). func (s *Service) enqueueScheduledPullSync(ctx context.Context, source *DatasourceSource) error { - return s.enqueuePullSync(ctx, source, DatasourceScheduledSyncSource, scheduledReconcileKey(source)) + _, err := s.enqueuePullSync(ctx, source, DatasourceScheduledSyncSource, scheduledReconcileKey(source)) + return err } // enqueuePullSync enqueues one pull-provider sync request, the job the leased // sync worker performs through RunDatasourceSync. It is exactly the job // SyncDatasourceSource enqueues for these providers; only provenance and -// idempotency key vary by caller. -func (s *Service) enqueuePullSync(ctx context.Context, source *DatasourceSource, provenance, idempotencyKey string) error { - _, err := s.datasourceJobs.EnqueueJob(ctx, &jobsv1.EnqueueJobRequest{ +// idempotency key vary by caller. It returns the job's id. +func (s *Service) enqueuePullSync(ctx context.Context, source *DatasourceSource, provenance, idempotencyKey string) (string, error) { + response, err := s.datasourceJobs.EnqueueJob(ctx, &jobsv1.EnqueueJobRequest{ Job: &jobsv1.NewJob{ Direction: jobsv1.JobDirection_JOB_DIRECTION_INBOX, Scope: &jobsv1.JobScope{Value: &jobsv1.JobScope_OrganizationId{OrganizationId: source.OrgID}}, @@ -1067,7 +1069,10 @@ func (s *Service) enqueuePullSync(ctx context.Context, source *DatasourceSource, Attributes: map[string]string{attrSourceID: source.ID}, }, }) - return err + if err != nil { + return "", err + } + return response.GetJobId(), nil } // pathInScope reports whether p is under one of the source's path prefixes at a diff --git a/module/services/accounts/code/pkg/business/datasource_file_filter.go b/module/services/accounts/code/pkg/business/datasource_file_filter.go index bc1777ae3..48ec0fa67 100644 --- a/module/services/accounts/code/pkg/business/datasource_file_filter.go +++ b/module/services/accounts/code/pkg/business/datasource_file_filter.go @@ -1,9 +1,11 @@ package business import ( + "fmt" "regexp" "strings" + "google.golang.org/genproto/googleapis/rpc/errdetails" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" ) @@ -53,3 +55,91 @@ func fileTypeAllowed(path string, extensions []string) bool { } return false } + +// Bounds on an object-storage source's key patterns. Go's regexp is RE2, so a +// tenant's expression runs in time linear in the key whatever it says; the +// bounds keep the per-key cost and the stored config small. +const ( + maxKeyPatterns = 16 + maxKeyPatternLength = 256 +) + +// keyFilterRefused is the refusal of one key-filter field: INVALID_ARGUMENT +// with a BadRequest violation naming the field, and a message that starts with +// the same path, which is what a client without the detail types reads. +func keyFilterRefused(field, reason string) error { + base := status.New(codes.InvalidArgument, field+": "+reason) + detailed, err := base.WithDetails(&errdetails.BadRequest{FieldViolations: []*errdetails.BadRequest_FieldViolation{{Field: field, Description: reason}}}) + if err != nil { + return base.Err() + } + return detailed.Err() +} + +// normalizeKeyPatterns trims, de-duplicates and compiles one pattern list, +// refusing an invalid expression with its field path and the pattern named. +func normalizeKeyPatterns(field string, values []string) ([]string, error) { + if len(values) > maxKeyPatterns { + return nil, keyFilterRefused(field, fmt.Sprintf("at most %d patterns", maxKeyPatterns)) + } + var out []string + seen := map[string]bool{} + for i, raw := range values { + value := strings.TrimSpace(raw) + path := fmt.Sprintf("%s[%d]", field, i) + switch { + case value == "": + return nil, keyFilterRefused(path, "a pattern cannot be empty") + case len(value) > maxKeyPatternLength: + return nil, keyFilterRefused(path, fmt.Sprintf("a pattern is at most %d characters", maxKeyPatternLength)) + } + if _, err := regexp.Compile(value); err != nil { + return nil, keyFilterRefused(path, fmt.Sprintf("%q is not a valid RE2 expression: %v", value, err)) + } + if !seen[value] { + out = append(out, value) + seen[value] = true + } + } + return out, nil +} + +// uploadKeyScope is an object-storage source's key filter, the InScope of its +// connector Scope as datasourceFileScope is a GitHub source's: a key is in scope +// when, with the prefix removed, it matches an include pattern (or there are +// none), matches no exclude pattern, and carries an admitted file type. The +// patterns were compiled when the config was accepted; one that no longer +// compiles admits nothing rather than everything. +func uploadKeyScope(cfg *UploadDatasourceConfig) func(key string) bool { + compile := func(patterns []string) ([]*regexp.Regexp, bool) { + out := make([]*regexp.Regexp, 0, len(patterns)) + for _, p := range patterns { + re, err := regexp.Compile(p) + if err != nil { + return nil, false + } + out = append(out, re) + } + return out, true + } + include, okInclude := compile(cfg.IncludePatterns) + exclude, okExclude := compile(cfg.ExcludePatterns) + if !okInclude || !okExclude { + return func(string) bool { return false } + } + matchesAny := func(res []*regexp.Regexp, s string) bool { + for _, re := range res { + if re.MatchString(s) { + return true + } + } + return false + } + return func(key string) bool { + relative := strings.TrimPrefix(key, cfg.Prefix) + if len(include) > 0 && !matchesAny(include, relative) { + return false + } + return !matchesAny(exclude, relative) && fileTypeAllowed(relative, cfg.FileExtensions) + } +} diff --git a/module/services/accounts/code/pkg/business/datasource_key_filter_test.go b/module/services/accounts/code/pkg/business/datasource_key_filter_test.go new file mode 100644 index 000000000..6c23928e7 --- /dev/null +++ b/module/services/accounts/code/pkg/business/datasource_key_filter_test.go @@ -0,0 +1,115 @@ +package business + +import ( + "strings" + "testing" + + "google.golang.org/genproto/googleapis/rpc/errdetails" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +// TestUploadKeyScope holds an object-storage source's key filter to its +// contract: patterns match the key with the prefix removed, unanchored and +// case-sensitive; an empty include admits everything; exclude wins; and file +// types intersect, case-insensitively, as on GitHub. +func TestUploadKeyScope(t *testing.T) { + cases := []struct { + name string + cfg UploadDatasourceConfig + admit []string + exclude []string + }{ + { + name: "no filter admits every key under the prefix", + cfg: UploadDatasourceConfig{Prefix: "kb/"}, + admit: []string{"kb/a.md", "kb/deep/b.pdf"}, + }, + { + name: "patterns see the key relative to the prefix", + cfg: UploadDatasourceConfig{Prefix: "kb/", IncludePatterns: []string{`^guides/`}}, + admit: []string{"kb/guides/a.md"}, + exclude: []string{"kb/notes/guides/a.md", "kb/a.md"}, + }, + { + name: "exclude wins over include", + cfg: UploadDatasourceConfig{Prefix: "kb/", IncludePatterns: []string{`\.md$`}, ExcludePatterns: []string{`^drafts/`}}, + admit: []string{"kb/guides/a.md"}, + exclude: []string{"kb/drafts/a.md", "kb/guides/a.pdf"}, + }, + { + name: "patterns are case-sensitive unless they fold case", + cfg: UploadDatasourceConfig{Prefix: "kb/", IncludePatterns: []string{`^Policies/`, `(?i)^handbook/`}}, + admit: []string{"kb/Policies/a.md", "kb/HANDBOOK/a.md"}, + exclude: []string{"kb/policies/a.md"}, + }, + { + name: "file types intersect with the patterns, case-insensitively", + cfg: UploadDatasourceConfig{Prefix: "kb/", IncludePatterns: []string{`^guides/`}, FileExtensions: []string{".md"}}, + admit: []string{"kb/guides/A.MD"}, + exclude: []string{"kb/guides/a.pdf", "kb/other/a.md"}, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + inScope := uploadKeyScope(&tc.cfg) + for _, key := range tc.admit { + if !inScope(key) { + t.Errorf("%q is excluded, want admitted", key) + } + } + for _, key := range tc.exclude { + if inScope(key) { + t.Errorf("%q is admitted, want excluded", key) + } + } + }) + } +} + +// TestNormalizeKeyPatternsRefusesByName: every refusal is INVALID_ARGUMENT and +// names the offending element, in the message a client reads and in the +// BadRequest violation a client can decode. +func TestNormalizeKeyPatternsRefusesByName(t *testing.T) { + tooMany := make([]string, maxKeyPatterns+1) + for i := range tooMany { + tooMany[i] = "a" + } + cases := []struct { + name string + patterns []string + field string + mention string + }{ + {"an invalid RE2 expression", []string{`\.md$`, `(unclosed`}, "upload.include_patterns[1]", `"(unclosed"`}, + {"a backreference RE2 does not support", []string{`(a)\1`}, "upload.include_patterns[0]", `"(a)\\1"`}, + {"an empty pattern", []string{" "}, "upload.include_patterns[0]", "empty"}, + {"a pattern over the length cap", []string{strings.Repeat("a", maxKeyPatternLength+1)}, "upload.include_patterns[0]", "256"}, + {"more patterns than the cap", tooMany, "upload.include_patterns", "16"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + _, err := normalizeKeyPatterns("upload.include_patterns", tc.patterns) + st := status.Convert(err) + if err == nil || st.Code() != codes.InvalidArgument { + t.Fatalf("err = %v, want INVALID_ARGUMENT", err) + } + if !strings.HasPrefix(st.Message(), tc.field+": ") || !strings.Contains(st.Message(), tc.mention) { + t.Fatalf("message %q does not name %s and %s", st.Message(), tc.field, tc.mention) + } + var field string + for _, d := range st.Details() { + if br, ok := d.(*errdetails.BadRequest); ok && len(br.GetFieldViolations()) == 1 { + field = br.GetFieldViolations()[0].GetField() + } + } + if field != tc.field { + t.Fatalf("BadRequest field = %q, want %q", field, tc.field) + } + }) + } + got, err := normalizeKeyPatterns("upload.exclude_patterns", []string{" ^drafts/ ", "^drafts/", `\.tmp$`}) + if err != nil || strings.Join(got, ",") != `^drafts/,\.tmp$` { + t.Fatalf("normalize = %v, %v; want trimmed and de-duplicated", got, err) + } +} diff --git a/module/services/accounts/code/pkg/business/datasource_update.go b/module/services/accounts/code/pkg/business/datasource_update.go index 05675e6c2..4b81c28cb 100644 --- a/module/services/accounts/code/pkg/business/datasource_update.go +++ b/module/services/accounts/code/pkg/business/datasource_update.go @@ -25,6 +25,9 @@ type DatasourceFetchScope struct { Branch string Paths []string FileExtensions []string + // An object-storage source's key patterns; FileExtensions is its file types. + IncludePatterns []string + ExcludePatterns []string } // UpdateSourceInput is what a connected source may be re-aimed at: its branch @@ -36,6 +39,10 @@ type UpdateSourceInput struct { Branch string Paths []string FileExtensions []string + // IncludePatterns and ExcludePatterns are an object-storage source's key + // patterns, replaced whole like the rest. + IncludePatterns []string + ExcludePatterns []string // Expected is the scope the caller was shown, which the edit is accepted // against. Required, and nil is refused rather than read as "apply it // anyway": a whole-field replacement applied unconditionally is precisely @@ -150,8 +157,14 @@ func (s *Service) UpdateSource(ctx context.Context, actorID string, input Update // providers are configured by their own fields (a base URL, a sitemap, a // bucket prefix), and silently accepting a branch for one of them would // report a change that changed nothing. + if source.Provider == DatasourceProviderUpload { + return s.updateUploadKeyFilter(ctx, actorID, source, input) + } if source.Provider != DatasourceProviderGitHub { - return nil, "", status.Error(codes.FailedPrecondition, "only a github source has a branch and path filters") + return nil, "", status.Error(codes.FailedPrecondition, "only a github or object-storage source has filters to edit") + } + if len(input.IncludePatterns) > 0 || len(input.ExcludePatterns) > 0 { + return nil, "", keyFilterRefused("include_patterns", "a github source has no key patterns") } branch := strings.TrimSpace(input.Branch) @@ -251,6 +264,95 @@ func (s *Service) UpdateSource(ctx context.Context, actorID string, input Update return &updated, jobID, nil } +// sameUploadKeyFilter reports whether scope is the key filter cfg holds, after +// the same normalization comparison sameFetchScope applies. +func sameUploadKeyFilter(scope DatasourceFetchScope, cfg *UploadDatasourceConfig) bool { + trimmed := func(values []string) []string { + var out []string + for _, v := range values { + if v = strings.TrimSpace(v); v != "" && !slices.Contains(out, v) { + out = append(out, v) + } + } + return out + } + return slices.Equal(trimmed(scope.IncludePatterns), trimmed(cfg.IncludePatterns)) && + slices.Equal(trimmed(scope.ExcludePatterns), trimmed(cfg.ExcludePatterns)) && + slices.Equal(comparableFileExtensions(scope.FileExtensions), comparableFileExtensions(cfg.FileExtensions)) +} + +// updateUploadKeyFilter is UpdateSource for an object-storage source: it +// replaces the key patterns and file types and nothing else (the bucket and +// prefix are the source's identity), under the same lock, expectation, audit +// and reconfigured sync as a github edit. The next sync lists through the new +// filter, so a narrowed filter's closing listing omits — and the store +// tombstones — every key it now excludes. +func (s *Service) updateUploadKeyFilter(ctx context.Context, actorID string, source *DatasourceSource, input UpdateSourceInput) (*DatasourceSource, string, error) { + w := wool.Get(ctx).In("updateUploadKeyFilter") + if strings.TrimSpace(input.Branch) != "" || len(input.Paths) > 0 { + return nil, "", keyFilterRefused("paths", "an object-storage source has no branch or paths; it is scoped by its prefix and key patterns") + } + var next UploadDatasourceConfig + if err := normalizeUploadKeyFilter(&next, &UploadDatasourceConfig{ + IncludePatterns: input.IncludePatterns, ExcludePatterns: input.ExcludePatterns, FileExtensions: input.FileExtensions, + }, ""); err != nil { + return nil, "", err + } + nextScope := DatasourceFetchScope{IncludePatterns: next.IncludePatterns, ExcludePatterns: next.ExcludePatterns, FileExtensions: next.FileExtensions} + var ( + updated DatasourceSource + changed bool + ) + if err := s.store.WithOrgTx(ctx, source.OrgID, func(ctx context.Context) error { + current, err := s.store.LockDatasourceSourceFetchScope(ctx, source.OrgID, source.ID) + if err != nil { + return err + } + if current == nil || current.Upload == nil { + return ErrDatasourceSourceNotFound + } + if !sameUploadKeyFilter(*input.Expected, current.Upload) { + return ErrDatasourceSourceScopeChanged + } + updated = *current + updated.BoundaryLabel = source.BoundaryLabel + if sameUploadKeyFilter(nextScope, current.Upload) { + return nil + } + cfg := *current.Upload + cfg.IncludePatterns, cfg.ExcludePatterns, cfg.FileExtensions = next.IncludePatterns, next.ExcludePatterns, next.FileExtensions + updated.Upload = &cfg + if err := s.store.UpdateDatasourceSourceFetchScope(ctx, &updated); err != nil { + return err + } + changed = true + return s.emitTx(ctx, actorID, "user", EventDatasourceSourceUpdated, "datasource", updated.ID, updated.OrgID, map[string]any{ + "bucket": cfg.Bucket, + "prefix": cfg.Prefix, + "include_patterns": cfg.IncludePatterns, + "exclude_patterns": cfg.ExcludePatterns, + "file_extensions": cfg.FileExtensions, + "boundary": updated.BoundaryNodeID, + }) + }); err != nil { + if errors.Is(err, ErrDatasourceSourceNotFound) || errors.Is(err, ErrDatasourceSourceScopeChanged) { + return nil, "", err + } + return nil, "", w.Wrapf(err, "persist datasource source update") + } + if !changed { + return &updated, "", nil + } + // A pull source re-syncs through its own engine, not GitHub's reconcile. + jobID, err := s.enqueuePullSync(ctx, &updated, datasourceSyncRequestSource, NewIDString()) + if err != nil { + w.Warn("enqueue re-sync failed; the periodic reconcile will re-sync the source", + wool.Field("source", updated.ID), wool.ErrField(err)) + return &updated, "", nil + } + return &updated, jobID, nil +} + // enqueueReconfiguredSync queues the re-snapshot an update owes the consuming // module: the same forced reconcile "Sync now" and the first sync at connect // enqueue, in the mode that names why it ran. diff --git a/module/services/accounts/code/pkg/business/datasource_upload_key_filter_test.go b/module/services/accounts/code/pkg/business/datasource_upload_key_filter_test.go new file mode 100644 index 000000000..54120b4e8 --- /dev/null +++ b/module/services/accounts/code/pkg/business/datasource_upload_key_filter_test.go @@ -0,0 +1,208 @@ +package business_test + +import ( + "context" + "reflect" + "sort" + "strings" + "testing" + + "accounts/pkg/business" + "accounts/pkg/datasource/objectstore" + + "google.golang.org/genproto/googleapis/rpc/errdetails" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +// refusedField is the field an INVALID_ARGUMENT refusal names, read from its +// BadRequest violation, after checking the message leads with the same path. +func refusedField(t *testing.T, err error) string { + t.Helper() + st := status.Convert(err) + if err == nil || st.Code() != codes.InvalidArgument { + t.Fatalf("err = %v, want INVALID_ARGUMENT", err) + } + for _, d := range st.Details() { + if br, ok := d.(*errdetails.BadRequest); ok && len(br.GetFieldViolations()) == 1 { + field := br.GetFieldViolations()[0].GetField() + if !strings.HasPrefix(st.Message(), field+": ") { + t.Fatalf("message %q does not lead with the field %q", st.Message(), field) + } + return field + } + } + t.Fatalf("refusal %v carries no BadRequest violation", err) + return "" +} + +// An invalid key filter is refused when the source is added — by name, and +// before anything is stored — never discovered at sync. +func TestAddSource_UploadRefusesAnInvalidKeyFilterByField(t *testing.T) { + cases := map[string]struct { + mutate func(*business.UploadDatasourceConfig) + field string + }{ + "invalid include": {func(c *business.UploadDatasourceConfig) { c.IncludePatterns = []string{`ok`, `[z-a]`} }, "upload.include_patterns[1]"}, + "invalid exclude": {func(c *business.UploadDatasourceConfig) { c.ExcludePatterns = []string{`^(?!drafts/)`} }, "upload.exclude_patterns[0]"}, + "invalid file type": {func(c *business.UploadDatasourceConfig) { c.FileExtensions = []string{"*.md"} }, "upload.file_extensions"}, + } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + store := newDatasourceFakeStore() + svc, _ := newDatasourceService(store, &recordingProducer{}, nil) + cfg := uploadConfig() + tc.mutate(cfg) + _, err := svc.AddExistingSource(context.Background(), "actor-1", business.AddSourceInput{ + OrgID: testOrg, Provider: business.DatasourceProviderUpload, CollectionLabel: "guides", + Credential: "secretkey", Upload: cfg, + }) + if got := refusedField(t, err); got != tc.field { + t.Fatalf("refused field = %q, want %q", got, tc.field) + } + if sources, _ := svc.ListDatasourceSources(context.Background(), testOrg); len(sources) != 0 { + t.Fatalf("a refused source was stored: %+v", sources) + } + }) + } +} + +// filteredUploadSource connects an object-storage source with a key filter. +func filteredUploadSource(t *testing.T, svc *business.Service, include, exclude []string) *business.DatasourceSource { + t.Helper() + cfg := uploadConfig() + cfg.IncludePatterns, cfg.ExcludePatterns = include, exclude + // The sync test lists four keys, three of them matches: an excluded key + // that used up the cap would leave its listing incomplete. + cfg.MaxObjects = 3 + source, err := svc.AddExistingSource(context.Background(), "actor-1", business.AddSourceInput{ + OrgID: testOrg, Provider: business.DatasourceProviderUpload, CollectionLabel: "guides", + Credential: "secretkey", Upload: cfg, + }) + if err != nil { + t.Fatal(err) + } + return source +} + +func uploadScopeOf(source *business.DatasourceSource) *business.DatasourceFetchScope { + return &business.DatasourceFetchScope{ + IncludePatterns: source.Upload.IncludePatterns, + ExcludePatterns: source.Upload.ExcludePatterns, + FileExtensions: source.Upload.FileExtensions, + } +} + +func TestUpdateSource_UploadRefusesAnInvalidKeyFilterByField(t *testing.T) { + store := newDatasourceFakeStore() + svc, audit := newDatasourceService(store, &recordingProducer{}, nil) + source := filteredUploadSource(t, svc, []string{`^guides/`}, nil) + + _, _, err := svc.UpdateSource(context.Background(), "actor-1", business.UpdateSourceInput{ + OrgID: testOrg, ID: source.ID, IncludePatterns: []string{`^guides/`}, ExcludePatterns: []string{`a{1001}`}, + Expected: uploadScopeOf(source), + }) + if got := refusedField(t, err); got != "exclude_patterns[0]" { + t.Fatalf("refused field = %q, want exclude_patterns[0]", got) + } + _, _, err = svc.UpdateSource(context.Background(), "actor-1", business.UpdateSourceInput{ + OrgID: testOrg, ID: source.ID, Paths: []string{"docs/"}, Expected: uploadScopeOf(source), + }) + if got := refusedField(t, err); got != "paths" { + t.Fatalf("refused field = %q, want paths: a bucket source has no paths", got) + } + stored, err := svc.GetDatasourceSource(context.Background(), testOrg, source.ID) + if err != nil || !reflect.DeepEqual(stored.Upload.ExcludePatterns, []string(nil)) { + t.Fatalf("a refused edit was written: %+v, %v", stored.Upload, err) + } + if entries := audit.entriesOf(business.EventDatasourceSourceUpdated); len(entries) != 0 { + t.Fatalf("updated events = %d, want 0 for a refused edit", len(entries)) + } +} + +func listedKeys(t *testing.T, l mirrorListing) []string { + t.Helper() + var keys []string + for _, f := range *l.Files { + keys = append(keys, f.Key) + } + sort.Strings(keys) + return keys +} + +func objectKeys(p *recordingProducer) []string { + var keys []string + for _, job := range uploadObjectJobs(p) { + keys = append(keys, job.GetAttributes()["upload.key"]) + } + sort.Strings(keys) + return keys +} + +// The mixed listing on the mirror seam: only matching keys are enqueued, an +// excluded key is never fetched, and the closing listing names only matches. +// Narrowing the filter through UpdateSource then re-syncs, and that sync's +// closing listing drops exactly the keys the narrower filter excludes — which +// is what tombstones them in the store — and nothing else. +func TestRunDatasourceSync_UploadKeyFilterNarrowingTombstonesOnlyTheExcluded(t *testing.T) { + producer := &recordingProducer{} + svc, audit := newDatasourceService(newDatasourceFakeStore(), producer, nil) + fake := &fakeUploadClient{ + entries: []objectstore.Entry{ + {Key: "kb/guides/a.md", ETag: "a"}, {Key: "kb/guides/b.pdf", ETag: "b"}, + {Key: "kb/drafts/c.md", ETag: "c"}, {Key: "kb/notes/d.md", ETag: "d"}, + }, + objects: map[string]objectstore.Object{}, + } + for _, e := range fake.entries { + fake.objects[e.Key] = objectstore.Object{Key: e.Key, Body: []byte(e.Key), ETag: e.ETag} + } + svc.SetDatasourceUploadClientFactory(func(business.UploadDatasourceConfig, string) business.UploadContentClient { return fake }) + source := filteredUploadSource(t, svc, []string{`\.md$`, `^guides/`}, []string{`^drafts/`}) + takeConnectSync(t, producer, source) + + if _, err := svc.RunDatasourceSync(context.Background(), source.ID); err != nil { + t.Fatal(err) + } + wide := []string{"kb/guides/a.md", "kb/guides/b.pdf", "kb/notes/d.md"} + if got := objectKeys(producer); !reflect.DeepEqual(got, wide) { + t.Fatalf("enqueued = %v, want %v", got, wide) + } + sort.Strings(fake.fetched) + if !reflect.DeepEqual(fake.fetched, wide) { + t.Fatalf("fetched = %v, want only the matches %v: an excluded key is never read", fake.fetched, wide) + } + if listings := uploadListings(t, producer); len(listings) != 1 || !reflect.DeepEqual(listedKeys(t, listings[0]), wide) { + t.Fatalf("closing listing does not name exactly the matches %v", wide) + } + + // Narrow: exclude the notes folder as well. + updated, jobID, err := svc.UpdateSource(context.Background(), "actor-1", business.UpdateSourceInput{ + OrgID: testOrg, ID: source.ID, + IncludePatterns: []string{`\.md$`, `^guides/`}, ExcludePatterns: []string{`^drafts/`, `^notes/`}, + Expected: uploadScopeOf(source), + }) + if err != nil || jobID == "" { + t.Fatalf("narrowing = %v, job %q; want a re-sync", err, jobID) + } + if entries := audit.entriesOf(business.EventDatasourceSourceUpdated); len(entries) != 1 { + t.Fatalf("updated events = %d, want 1", len(entries)) + } + if _, _, err := svc.UpdateSource(context.Background(), "actor-1", business.UpdateSourceInput{ + OrgID: testOrg, ID: source.ID, IncludePatterns: []string{`.*`}, Expected: uploadScopeOf(source), + }); err != business.ErrDatasourceSourceScopeChanged { + t.Fatalf("an edit against the scope before the narrowing = %v, want ErrDatasourceSourceScopeChanged", err) + } + + producer.mu.Lock() + producer.jobs = nil + producer.mu.Unlock() + if _, err := svc.RunDatasourceSync(context.Background(), updated.ID); err != nil { + t.Fatal(err) + } + narrow := []string{"kb/guides/a.md", "kb/guides/b.pdf"} + listings := uploadListings(t, producer) + if len(listings) != 1 || !reflect.DeepEqual(listedKeys(t, listings[0]), narrow) { + t.Fatalf("closing listing after narrowing does not name exactly %v: kb/notes/d.md must be the only key dropped", narrow) + } +} diff --git a/module/services/accounts/code/pkg/business/datasources.go b/module/services/accounts/code/pkg/business/datasources.go index fc692103e..a2bfa4f6e 100644 --- a/module/services/accounts/code/pkg/business/datasources.go +++ b/module/services/accounts/code/pkg/business/datasources.go @@ -365,6 +365,11 @@ type UploadDatasourceConfig struct { Prefix string `json:"prefix,omitempty"` AccessKeyID string `json:"access_key_id"` MaxObjects int `json:"max_objects,omitempty"` + // Which keys under the prefix are ingested: RE2 patterns over the key with + // the prefix removed, and a file-type allowlist (uploadKeyScope). + IncludePatterns []string `json:"include_patterns,omitempty"` + ExcludePatterns []string `json:"exclude_patterns,omitempty"` + FileExtensions []string `json:"file_extensions,omitempty"` } // DatasourceSource is one connected external datasource. CredentialSecretRef and @@ -560,7 +565,6 @@ func (s *Service) SetDatasourceConnector(cipher SecretCipher, producer jobs.Prod Bucket: cfg.Bucket, Prefix: cfg.Prefix, AccessKeyID: cfg.AccessKeyID, - MaxObjects: cfg.MaxObjects, }, secretAccessKey) } } @@ -934,6 +938,10 @@ func (s *Service) addSource(ctx context.Context, actorID string, input AddSource } cfg, err := normalizeUploadConfig(input.Upload) if err != nil { + // A key-filter refusal names its field; it reaches the caller as itself. + if status.Code(err) == codes.InvalidArgument { + return nil, err + } return nil, w.Wrap(err) } source.Upload = cfg @@ -1117,6 +1125,22 @@ func normalizeCrawlerConfig(cfg *CrawlerDatasourceConfig) (*CrawlerDatasourceCon return &CrawlerDatasourceConfig{SitemapURL: sitemapURL, MaxPages: maxPages}, nil } +// normalizeUploadKeyFilter validates from's key filter into to, naming each +// field under path (its place in the request) when it refuses one. +func normalizeUploadKeyFilter(to, from *UploadDatasourceConfig, path string) error { + var err error + if to.IncludePatterns, err = normalizeKeyPatterns(path+"include_patterns", from.IncludePatterns); err != nil { + return err + } + if to.ExcludePatterns, err = normalizeKeyPatterns(path+"exclude_patterns", from.ExcludePatterns); err != nil { + return err + } + if to.FileExtensions, err = normalizeFileExtensions(from.FileExtensions); err != nil { + return keyFilterRefused(path+"file_extensions", status.Convert(err).Message()) + } + return nil +} + // normalizeUploadConfig validates and trims an object-storage provider config. func normalizeUploadConfig(cfg *UploadDatasourceConfig) (*UploadDatasourceConfig, error) { if cfg == nil { @@ -1147,6 +1171,9 @@ func normalizeUploadConfig(cfg *UploadDatasourceConfig) (*UploadDatasourceConfig if out.MaxObjects < 0 { return nil, errors.New("upload max objects must not be negative") } + if err := normalizeUploadKeyFilter(out, cfg, "upload."); err != nil { + return nil, err + } return out, nil } @@ -1921,6 +1948,13 @@ func (s *Service) runUploadSync(ctx context.Context, source *DatasourceSource) ( if err != nil { return 0, w.Wrapf(err, "list objects") } + // The source's key filter and object cap are the connector's Scope, applied + // to the listing before anything is fetched: an excluded object is never + // read and never counts against the cap, and the closing listing — built + // from what was delivered — names only matches, so narrowing the filter + // tombstones what it now excludes on the next sync. + scope := objectstore.Scope{InScope: uploadKeyScope(source.Upload), MaxObjects: source.Upload.MaxObjects} + entries, whole := objectstore.Window(scope, listing.Entries, func(e objectstore.Entry) string { return e.Key }) ordinal, err := s.allocateOrdinal(ctx, source.ID) if err != nil { return 0, w.Wrapf(err, "allocate ordinal") @@ -1928,8 +1962,8 @@ func (s *Service) runUploadSync(ctx context.Context, source *DatasourceSource) ( enqueued := 0 oversized := 0 - delivered := make([]uploadListingFile, 0, len(listing.Entries)) - for _, entry := range listing.Entries { + delivered := make([]uploadListingFile, 0, len(entries)) + for _, entry := range entries { if strings.HasSuffix(entry.Key, "/") { continue } @@ -1965,8 +1999,8 @@ func (s *Service) runUploadSync(ctx context.Context, source *DatasourceSource) ( if oversized > 0 { return enqueued, w.NewError("%d object(s) exceed the ingest payload limit", oversized) } - if !listing.Complete { - return enqueued, w.Wrapf(ErrUploadListingIncomplete, "listing of %d object(s) under %q", len(listing.Entries), source.Upload.Prefix) + if !listing.Complete || !whole { + return enqueued, w.Wrapf(ErrUploadListingIncomplete, "listing of %d object(s) under %q", len(entries), source.Upload.Prefix) } if err := s.enqueueUploadListing(ctx, source, ordinal, delivered); err != nil { return enqueued, w.Wrapf(err, "enqueue listing") diff --git a/module/services/accounts/code/pkg/business/datasources_test.go b/module/services/accounts/code/pkg/business/datasources_test.go index 9be154ded..cea718a6b 100644 --- a/module/services/accounts/code/pkg/business/datasources_test.go +++ b/module/services/accounts/code/pkg/business/datasources_test.go @@ -143,7 +143,7 @@ func (f *datasourceFakeStore) InsertDatasourceSource(_ context.Context, source * } // UpdateDatasourceSourceFetchScope mirrors the store's narrow UPDATE: only the -// three fetch-scope fields are written, so a business change that tried to move +// fetch-scope fields and the config envelope are written, so a business change that tried to move // a source's repository or credential through this call fails here as it would // against the statement. // @@ -162,6 +162,12 @@ func (f *datasourceFakeStore) UpdateDatasourceSourceFetchScope(_ context.Context stored.Branch = source.Branch stored.Paths = source.Paths stored.FileExtensions = source.FileExtensions + // The statement also rewrites the config envelope, which for an + // object-storage source is its whole config, key filter included. + if source.Upload != nil { + cfg := *source.Upload + stored.Upload = &cfg + } return nil } @@ -1276,6 +1282,7 @@ type fakeUploadClient struct { incomplete bool listCalls int fetchCalls int + fetched []string } func (f *fakeUploadClient) List(context.Context) (objectstore.Listing, error) { @@ -1288,6 +1295,7 @@ func (f *fakeUploadClient) List(context.Context) (objectstore.Listing, error) { func (f *fakeUploadClient) Fetch(_ context.Context, key string) (objectstore.Object, error) { f.fetchCalls++ + f.fetched = append(f.fetched, key) if err := f.fetchErr[key]; err != nil { return objectstore.Object{}, err } diff --git a/module/services/accounts/code/pkg/datasource/objectstore/client.go b/module/services/accounts/code/pkg/datasource/objectstore/client.go index 8d85fbba4..d90cb1563 100644 --- a/module/services/accounts/code/pkg/datasource/objectstore/client.go +++ b/module/services/accounts/code/pkg/datasource/objectstore/client.go @@ -38,14 +38,6 @@ import ( // cannot exhaust memory or overflow the downstream job payload limit. const maxResponseBytes = 5 * 1024 * 1024 -// defaultMaxObjects caps how many objects one Fetch returns when the source does -// not configure a limit; maxObjectsCeiling is the hard upper bound regardless of -// configuration. -const ( - defaultMaxObjects = 100 - maxObjectsCeiling = 1000 -) - const maxRedirects = 3 // emptyPayloadHash is the SHA-256 of the empty payload, used as @@ -74,7 +66,6 @@ type Config struct { Bucket string Prefix string // key prefix to list under; may be empty AccessKeyID string - MaxObjects int // 0 means the default cap } // Object is one fetched object: its key, body bytes, content type, and ETag. @@ -159,18 +150,19 @@ type Entry struct { } // Listing is one List result. Complete is true only when the entries are every -// object under the prefix: the store answered with IsTruncated false and the -// object cap cut nothing. A caller that treats absence from a listing as a -// deletion must act only on a complete one. +// object under the prefix: the store answered with IsTruncated false. A caller +// that treats absence from a listing as a deletion must act only on a complete +// one. type Listing struct { Entries []Entry Complete bool } -// List issues one ListObjectsV2 request and returns its entries, bounded by the -// configured object cap. Listing failure (including a non-2xx list) is a returned -// error so a credential or permission problem surfaces rather than reading as an -// empty bucket. +// List issues one ListObjectsV2 request and returns every entry of the page the +// store answers with; which of them a sync takes, and how many, is its Scope's +// to decide (Window), not the listing's. Listing failure (including a non-2xx +// list) is a returned error so a credential or permission problem surfaces +// rather than reading as an empty bucket. func (c *Client) List(ctx context.Context) (Listing, error) { if c.baseErr != nil { return Listing{}, c.baseErr @@ -193,21 +185,11 @@ func (c *Client) List(ctx context.Context) (Listing, error) { if err := xml.Unmarshal(body, &parsed); err != nil { return Listing{}, fmt.Errorf("objectstore: parse listing: %w", err) } - limit := c.cfg.MaxObjects - if limit <= 0 { - limit = defaultMaxObjects - } - if limit > maxObjectsCeiling { - limit = maxObjectsCeiling - } entries := make([]Entry, 0, len(parsed.Contents)) for _, e := range parsed.Contents { - if len(entries) >= limit { - break - } entries = append(entries, Entry{Key: e.Key, ETag: e.ETag}) } - return Listing{Entries: entries, Complete: !parsed.IsTruncated && len(parsed.Contents) <= limit}, nil + return Listing{Entries: entries, Complete: !parsed.IsTruncated}, nil } // listEntry is one row of a ListBucketResult. diff --git a/module/services/accounts/code/pkg/datasource/objectstore/client_test.go b/module/services/accounts/code/pkg/datasource/objectstore/client_test.go index f0bac7031..a6cd07e34 100644 --- a/module/services/accounts/code/pkg/datasource/objectstore/client_test.go +++ b/module/services/accounts/code/pkg/datasource/objectstore/client_test.go @@ -299,8 +299,9 @@ func TestSSRFGuardBlocksLoopback(t *testing.T) { } // TestListReportsATruncatedListingIncomplete pins the completeness signal a -// mirror deletes by: a page the store marks IsTruncated, or one the object cap -// cut, is never complete, while an untruncated page within the cap is. +// mirror deletes by: a page the store marks IsTruncated is never complete, +// while an untruncated one is, and either way the client returns the whole +// page — the cap is the sync's Scope, not the listing's (TestWindow). func TestListReportsATruncatedListingIncomplete(t *testing.T) { allowDial(t) truncated := "false" @@ -314,29 +315,18 @@ func TestListReportsATruncatedListingIncomplete(t *testing.T) { `)) })) defer srv.Close() - client := func(max int) *Client { - return New(Config{Endpoint: srv.URL, Region: "us-east-1", Bucket: "b", Prefix: "docs/", AccessKeyID: "AK", MaxObjects: max}, "secret") - } + client := New(Config{Endpoint: srv.URL, Region: "us-east-1", Bucket: "b", Prefix: "docs/", AccessKeyID: "AK"}, "secret") for _, tc := range []struct { - name string truncated string - max int - entries int complete bool - }{ - {"whole listing within the cap", "false", 3, 3, true}, - {"store truncated the page", "true", 10, 3, false}, - {"object cap cut the page", "false", 2, 2, false}, - } { - t.Run(tc.name, func(t *testing.T) { - truncated = tc.truncated - listing, err := client(tc.max).List(context.Background()) - if err != nil { - t.Fatal(err) - } - if len(listing.Entries) != tc.entries || listing.Complete != tc.complete { - t.Fatalf("entries=%d complete=%v, want %d/%v", len(listing.Entries), listing.Complete, tc.entries, tc.complete) - } - }) + }{{"false", true}, {"true", false}} { + truncated = tc.truncated + listing, err := client.List(context.Background()) + if err != nil { + t.Fatal(err) + } + if len(listing.Entries) != 3 || listing.Complete != tc.complete { + t.Fatalf("IsTruncated=%s: entries=%d complete=%v, want 3/%v", tc.truncated, len(listing.Entries), listing.Complete, tc.complete) + } } } diff --git a/module/services/accounts/code/pkg/datasource/objectstore/scope.go b/module/services/accounts/code/pkg/datasource/objectstore/scope.go new file mode 100644 index 000000000..104f4f775 --- /dev/null +++ b/module/services/accounts/code/pkg/datasource/objectstore/scope.go @@ -0,0 +1,50 @@ +package objectstore + +// defaultMaxObjects caps how many objects one sync takes when the source does +// not configure a limit; maxObjectsCeiling is the hard upper bound regardless of +// configuration. +const ( + defaultMaxObjects = 100 + maxObjectsCeiling = 1000 +) + +// Scope is which of a prefix's objects one sync takes: those whose key InScope +// admits — the host's key filter for the source, as a GitHub source's +// SourceConfig.InScope is its path scope — up to the object cap. It belongs to +// the connector, not to what lists the prefix: a listing source hands Window +// everything it listed, so replacing the listing source leaves the filter and +// the cap where they are. +type Scope struct { + InScope func(key string) bool // nil admits every key + MaxObjects int // 0 means the default cap +} + +func (s Scope) limit() int { + switch { + case s.MaxObjects <= 0: + return defaultMaxObjects + case s.MaxObjects > maxObjectsCeiling: + return maxObjectsCeiling + } + return s.MaxObjects +} + +// Window keeps the listed items whose key the scope admits, in listing order +// and at most the cap of them. An item it refuses is dropped before it is +// counted, so an excluded object never uses up the cap and is never fetched. +// whole is false when the cap cut an admitted item: a caller that reads +// absence as deletion must then not act on what it kept. +func Window[T any](s Scope, items []T, key func(T) string) (kept []T, whole bool) { + limit := s.limit() + kept = make([]T, 0, min(len(items), limit)) + admitted := 0 + for _, item := range items { + if s.InScope != nil && !s.InScope(key(item)) { + continue + } + if admitted++; admitted <= limit { + kept = append(kept, item) + } + } + return kept, admitted <= limit +} diff --git a/module/services/accounts/code/pkg/datasource/objectstore/scope_test.go b/module/services/accounts/code/pkg/datasource/objectstore/scope_test.go new file mode 100644 index 000000000..61f576704 --- /dev/null +++ b/module/services/accounts/code/pkg/datasource/objectstore/scope_test.go @@ -0,0 +1,44 @@ +package objectstore + +import ( + "slices" + "strings" + "testing" +) + +// TestWindow pins what a sync takes of a listing: the admitted keys in listing +// order, at most the cap, with an excluded key dropped before it is counted. +func TestWindow(t *testing.T) { + listed := []Entry{{Key: "docs/a.md"}, {Key: "docs/drafts/b.md"}, {Key: "docs/c.md"}, {Key: "docs/d.pdf"}} + key := func(e Entry) string { return e.Key } + noDrafts := func(k string) bool { return !strings.HasPrefix(k, "docs/drafts/") } + for _, tc := range []struct { + name string + scope Scope + keys []string + whole bool + }{ + {"no filter, within the cap", Scope{MaxObjects: 4}, []string{"docs/a.md", "docs/drafts/b.md", "docs/c.md", "docs/d.pdf"}, true}, + {"the cap cut an admitted key", Scope{MaxObjects: 2}, []string{"docs/a.md", "docs/drafts/b.md"}, false}, + {"an excluded key never uses up the cap", Scope{InScope: noDrafts, MaxObjects: 3}, []string{"docs/a.md", "docs/c.md", "docs/d.pdf"}, true}, + {"the cap counts only admitted keys", Scope{InScope: noDrafts, MaxObjects: 2}, []string{"docs/a.md", "docs/c.md"}, false}, + {"no cap configured is the default", Scope{InScope: noDrafts}, []string{"docs/a.md", "docs/c.md", "docs/d.pdf"}, true}, + } { + t.Run(tc.name, func(t *testing.T) { + kept, whole := Window(tc.scope, listed, key) + var keys []string + for _, e := range kept { + keys = append(keys, e.Key) + } + if !slices.Equal(keys, tc.keys) || whole != tc.whole { + t.Fatalf("kept %v whole=%v, want %v whole=%v", keys, whole, tc.keys, tc.whole) + } + }) + } + if got := (Scope{MaxObjects: 5000}).limit(); got != maxObjectsCeiling { + t.Fatalf("a cap above the ceiling = %d, want %d", got, maxObjectsCeiling) + } + if got := (Scope{}).limit(); got != defaultMaxObjects { + t.Fatalf("no cap = %d, want %d", got, defaultMaxObjects) + } +} diff --git a/module/services/accounts/code/pkg/gen/saas/accounts/v1/datasource.pb.go b/module/services/accounts/code/pkg/gen/saas/accounts/v1/datasource.pb.go index c223254c6..3ba78e16a 100644 --- a/module/services/accounts/code/pkg/gen/saas/accounts/v1/datasource.pb.go +++ b/module/services/accounts/code/pkg/gen/saas/accounts/v1/datasource.pb.go @@ -1249,9 +1249,25 @@ type UploadDatasourceConfig struct { // AWS-style access key id. The matching secret access key is the credential. AccessKeyId string `protobuf:"bytes,5,opt,name=access_key_id,json=accessKeyId,proto3" json:"access_key_id,omitempty"` // Upper bound on objects fetched per sync. Zero applies the connector default. - MaxObjects uint32 `protobuf:"varint,6,opt,name=max_objects,json=maxObjects,proto3" json:"max_objects,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // Only objects that pass the filters below count against it. + MaxObjects uint32 `protobuf:"varint,6,opt,name=max_objects,json=maxObjects,proto3" json:"max_objects,omitempty"` + // Which keys under the prefix are ingested. Each pattern is an RE2 regular + // expression matched against the object key with the prefix removed, + // unanchored and case-sensitive (anchor with ^ and $, fold case with (?i)). + // An empty include admits every key; an exclude match always wins. Applied + // when the prefix is listed, before anything is fetched, so an excluded + // object is never read and never counts against max_objects. An invalid + // expression is refused when the source is added or updated, never at sync: + // the call fails INVALID_ARGUMENT with a google.rpc.BadRequest violation, and + // a message that starts with the field's path, e.g. + // "upload.include_patterns[1]: …". + IncludePatterns []string `protobuf:"bytes,7,rep,name=include_patterns,json=includePatterns,proto3" json:"include_patterns,omitempty"` + ExcludePatterns []string `protobuf:"bytes,8,rep,name=exclude_patterns,json=excludePatterns,proto3" json:"exclude_patterns,omitempty"` + // Case-insensitive key suffixes, e.g. [".md", ".pdf"], intersected with the + // patterns. Empty admits every type. GitHub's file_extensions semantics. + FileExtensions []string `protobuf:"bytes,9,rep,name=file_extensions,json=fileExtensions,proto3" json:"file_extensions,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *UploadDatasourceConfig) Reset() { @@ -1326,6 +1342,27 @@ func (x *UploadDatasourceConfig) GetMaxObjects() uint32 { return 0 } +func (x *UploadDatasourceConfig) GetIncludePatterns() []string { + if x != nil { + return x.IncludePatterns + } + return nil +} + +func (x *UploadDatasourceConfig) GetExcludePatterns() []string { + if x != nil { + return x.ExcludePatterns + } + return nil +} + +func (x *UploadDatasourceConfig) GetFileExtensions() []string { + if x != nil { + return x.FileExtensions + } + return nil +} + // Datasource is one connected external datasource. It is org-scoped and carries // only non-secret projection fields; credential material is deliberately absent. // Exactly one provider config (github, api, crawler, or upload) is set, matching @@ -2084,9 +2121,14 @@ type UpdateSourceRequest struct { // on every successful sync, which would refuse a perfectly good edit for the // sole reason that the source had reconciled since the form was opened. What // an editor needs guarded is what they were looking at. - Expected *SourceFetchScope `protobuf:"bytes,6,opt,name=expected,proto3" json:"expected,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + Expected *SourceFetchScope `protobuf:"bytes,6,opt,name=expected,proto3" json:"expected,omitempty"` + // An object-storage source's key patterns (see UploadDatasourceConfig), + // replaced whole like every field here. Its file_extensions is field 5; its + // branch and paths must be empty. Refused for a github source. + IncludePatterns []string `protobuf:"bytes,7,rep,name=include_patterns,json=includePatterns,proto3" json:"include_patterns,omitempty"` + ExcludePatterns []string `protobuf:"bytes,8,rep,name=exclude_patterns,json=excludePatterns,proto3" json:"exclude_patterns,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *UpdateSourceRequest) Reset() { @@ -2161,6 +2203,20 @@ func (x *UpdateSourceRequest) GetExpected() *SourceFetchScope { return nil } +func (x *UpdateSourceRequest) GetIncludePatterns() []string { + if x != nil { + return x.IncludePatterns + } + return nil +} + +func (x *UpdateSourceRequest) GetExcludePatterns() []string { + if x != nil { + return x.ExcludePatterns + } + return nil +} + // SourceFetchScope is what a source fetches — the three values UpdateSource // replaces — carried on its own so a request can state the scope it expects to // be replacing as well as the one it wants. @@ -2171,12 +2227,14 @@ func (x *UpdateSourceRequest) GetExpected() *SourceFetchScope { // edit at all. Size is still bounded, because the comparison reads the whole // thing. type SourceFetchScope struct { - state protoimpl.MessageState `protogen:"open.v1"` - Branch string `protobuf:"bytes,1,opt,name=branch,proto3" json:"branch,omitempty"` - Paths []string `protobuf:"bytes,2,rep,name=paths,proto3" json:"paths,omitempty"` - FileExtensions []string `protobuf:"bytes,3,rep,name=file_extensions,json=fileExtensions,proto3" json:"file_extensions,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + state protoimpl.MessageState `protogen:"open.v1"` + Branch string `protobuf:"bytes,1,opt,name=branch,proto3" json:"branch,omitempty"` + Paths []string `protobuf:"bytes,2,rep,name=paths,proto3" json:"paths,omitempty"` + FileExtensions []string `protobuf:"bytes,3,rep,name=file_extensions,json=fileExtensions,proto3" json:"file_extensions,omitempty"` + IncludePatterns []string `protobuf:"bytes,4,rep,name=include_patterns,json=includePatterns,proto3" json:"include_patterns,omitempty"` + ExcludePatterns []string `protobuf:"bytes,5,rep,name=exclude_patterns,json=excludePatterns,proto3" json:"exclude_patterns,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *SourceFetchScope) Reset() { @@ -2230,6 +2288,20 @@ func (x *SourceFetchScope) GetFileExtensions() []string { return nil } +func (x *SourceFetchScope) GetIncludePatterns() []string { + if x != nil { + return x.IncludePatterns + } + return nil +} + +func (x *SourceFetchScope) GetExcludePatterns() []string { + if x != nil { + return x.ExcludePatterns + } + return nil +} + type UpdateSourceResponse struct { state protoimpl.MessageState `protogen:"open.v1"` Datasource *Datasource `protobuf:"bytes,1,opt,name=datasource,proto3" json:"datasource,omitempty"` @@ -7463,7 +7535,7 @@ const file_saas_accounts_v1_datasource_proto_rawDesc = "" + "\x17CrawlerDatasourceConfig\x12\x1f\n" + "\vsitemap_url\x18\x01 \x01(\tR\n" + "sitemapUrl\x12\x1b\n" + - "\tmax_pages\x18\x02 \x01(\rR\bmaxPages\"\xc1\x01\n" + + "\tmax_pages\x18\x02 \x01(\rR\bmaxPages\"\x98\x03\n" + "\x16UploadDatasourceConfig\x12\x1a\n" + "\bendpoint\x18\x01 \x01(\tR\bendpoint\x12\x16\n" + "\x06region\x18\x02 \x01(\tR\x06region\x12\x16\n" + @@ -7471,7 +7543,10 @@ const file_saas_accounts_v1_datasource_proto_rawDesc = "" + "\x06prefix\x18\x04 \x01(\tR\x06prefix\x12\"\n" + "\raccess_key_id\x18\x05 \x01(\tR\vaccessKeyId\x12\x1f\n" + "\vmax_objects\x18\x06 \x01(\rR\n" + - "maxObjects\"\x83\t\n" + + "maxObjects\x12<\n" + + "\x10include_patterns\x18\a \x03(\tB\x11\xbaH\x0e\x92\x01\v\x10\x10\"\ar\x05\x10\x01\x18\x80\x02R\x0fincludePatterns\x12<\n" + + "\x10exclude_patterns\x18\b \x03(\tB\x11\xbaH\x0e\x92\x01\v\x10\x10\"\ar\x05\x10\x01\x18\x80\x02R\x0fexcludePatterns\x12Y\n" + + "\x0ffile_extensions\x18\t \x03(\tB0\xbaH-\x92\x01*\x10 \"&r$\x10\x02\x18!2\x1e^\\.[A-Za-z0-9][A-Za-z0-9._-]*$R\x0efileExtensions\"\x83\t\n" + "\n" + "Datasource\x12\x0e\n" + "\x02id\x18\x01 \x01(\tR\x02id\x12\x15\n" + @@ -7539,19 +7614,23 @@ const file_saas_accounts_v1_datasource_proto_rawDesc = "" + "\x11AddSourceResponse\x12<\n" + "\n" + "datasource\x18\x01 \x01(\v2\x1c.saas.accounts.v1.DatasourceR\n" + - "datasource\"\xbe\x02\n" + + "datasource\"\xba\x03\n" + "\x13UpdateSourceRequest\x12\x1f\n" + "\x06org_id\x18\x01 \x01(\tB\b\xbaH\x05r\x03\xb0\x01\x01R\x05orgId\x12\x18\n" + "\x02id\x18\x02 \x01(\tB\b\xbaH\x05r\x03\xb0\x01\x01R\x02id\x12 \n" + "\x06branch\x18\x03 \x01(\tB\b\xbaH\x05r\x03\x18\xff\x01R\x06branch\x12'\n" + "\x05paths\x18\x04 \x03(\tB\x11\xbaH\x0e\x92\x01\v\x10@\"\ar\x05\x10\x01\x18\x80\x04R\x05paths\x12Y\n" + "\x0ffile_extensions\x18\x05 \x03(\tB0\xbaH-\x92\x01*\x10 \"&r$\x10\x02\x18!2\x1e^\\.[A-Za-z0-9][A-Za-z0-9._-]*$R\x0efileExtensions\x12F\n" + - "\bexpected\x18\x06 \x01(\v2\".saas.accounts.v1.SourceFetchScopeB\x06\xbaH\x03\xc8\x01\x01R\bexpected\"\x98\x01\n" + + "\bexpected\x18\x06 \x01(\v2\".saas.accounts.v1.SourceFetchScopeB\x06\xbaH\x03\xc8\x01\x01R\bexpected\x12<\n" + + "\x10include_patterns\x18\a \x03(\tB\x11\xbaH\x0e\x92\x01\v\x10\x10\"\ar\x05\x10\x01\x18\x80\x02R\x0fincludePatterns\x12<\n" + + "\x10exclude_patterns\x18\b \x03(\tB\x11\xbaH\x0e\x92\x01\v\x10\x10\"\ar\x05\x10\x01\x18\x80\x02R\x0fexcludePatterns\"\x90\x02\n" + "\x10SourceFetchScope\x12 \n" + "\x06branch\x18\x01 \x01(\tB\b\xbaH\x05r\x03\x18\xff\x01R\x06branch\x12'\n" + "\x05paths\x18\x02 \x03(\tB\x11\xbaH\x0e\x92\x01\v\x10@\"\ar\x05\x10\x01\x18\x80\x04R\x05paths\x129\n" + "\x0ffile_extensions\x18\x03 \x03(\tB\x10\xbaH\r\x92\x01\n" + - "\x10 \"\x06r\x04\x10\x01\x18!R\x0efileExtensions\"k\n" + + "\x10 \"\x06r\x04\x10\x01\x18!R\x0efileExtensions\x12:\n" + + "\x10include_patterns\x18\x04 \x03(\tB\x0f\xbaH\f\x92\x01\t\x10\x10\"\x05r\x03\x18\x80\x02R\x0fincludePatterns\x12:\n" + + "\x10exclude_patterns\x18\x05 \x03(\tB\x0f\xbaH\f\x92\x01\t\x10\x10\"\x05r\x03\x18\x80\x02R\x0fexcludePatterns\"k\n" + "\x14UpdateSourceResponse\x12<\n" + "\n" + "datasource\x18\x01 \x01(\v2\x1c.saas.accounts.v1.DatasourceR\n" + diff --git a/module/services/accounts/proto/saas/accounts/v1/datasource.proto b/module/services/accounts/proto/saas/accounts/v1/datasource.proto index 8a75033fc..dac135132 100644 --- a/module/services/accounts/proto/saas/accounts/v1/datasource.proto +++ b/module/services/accounts/proto/saas/accounts/v1/datasource.proto @@ -189,7 +189,48 @@ message UploadDatasourceConfig { // AWS-style access key id. The matching secret access key is the credential. string access_key_id = 5; // Upper bound on objects fetched per sync. Zero applies the connector default. + // Only objects that pass the filters below count against it. uint32 max_objects = 6; + // Which keys under the prefix are ingested. Each pattern is an RE2 regular + // expression matched against the object key with the prefix removed, + // unanchored and case-sensitive (anchor with ^ and $, fold case with (?i)). + // An empty include admits every key; an exclude match always wins. Applied + // when the prefix is listed, before anything is fetched, so an excluded + // object is never read and never counts against max_objects. An invalid + // expression is refused when the source is added or updated, never at sync: + // the call fails INVALID_ARGUMENT with a google.rpc.BadRequest violation, and + // a message that starts with the field's path, e.g. + // "upload.include_patterns[1]: …". + repeated string include_patterns = 7 [(buf.validate.field).repeated = { + max_items: 16 + items: { + string: { + min_len: 1 + max_len: 256 + } + } + }]; + repeated string exclude_patterns = 8 [(buf.validate.field).repeated = { + max_items: 16 + items: { + string: { + min_len: 1 + max_len: 256 + } + } + }]; + // Case-insensitive key suffixes, e.g. [".md", ".pdf"], intersected with the + // patterns. Empty admits every type. GitHub's file_extensions semantics. + repeated string file_extensions = 9 [(buf.validate.field).repeated = { + max_items: 32 + items: { + string: { + min_len: 2 + max_len: 33 + pattern: "^\\.[A-Za-z0-9][A-Za-z0-9._-]*$" + } + } + }]; } // Datasource is one connected external datasource. It is org-scoped and carries @@ -437,6 +478,27 @@ message UpdateSourceRequest { // sole reason that the source had reconciled since the form was opened. What // an editor needs guarded is what they were looking at. SourceFetchScope expected = 6 [(buf.validate.field).required = true]; + // An object-storage source's key patterns (see UploadDatasourceConfig), + // replaced whole like every field here. Its file_extensions is field 5; its + // branch and paths must be empty. Refused for a github source. + repeated string include_patterns = 7 [(buf.validate.field).repeated = { + max_items: 16 + items: { + string: { + min_len: 1 + max_len: 256 + } + } + }]; + repeated string exclude_patterns = 8 [(buf.validate.field).repeated = { + max_items: 16 + items: { + string: { + min_len: 1 + max_len: 256 + } + } + }]; } // SourceFetchScope is what a source fetches — the three values UpdateSource @@ -467,6 +529,17 @@ message SourceFetchScope { max_len: 33 } } + }]; repeated string include_patterns = 4 [(buf.validate.field).repeated = { + max_items: 16 + items: { + string: {max_len: 256} + } + }]; + repeated string exclude_patterns = 5 [(buf.validate.field).repeated = { + max_items: 16 + items: { + string: {max_len: 256} + } }]; } diff --git a/module/services/frontend/code/package-lock.json b/module/services/frontend/code/package-lock.json index b8ba2435b..2a136c7cb 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.35", + "@codefly-dev/saas-sdk": "0.3.36", "@codefly-dev/saas-ui": "0.12.8", "@codefly-dev/ui": "0.12.8", "@codefly/saas-plugin-manifest": "0.1.2", @@ -15369,7 +15369,7 @@ }, "packages/codefly-ui": { "name": "@codefly-dev/ui", - "version": "0.12.15", + "version": "0.12.16", "license": "Apache-2.0", "dependencies": { "@base-ui/react": "^1.3.0", @@ -15484,7 +15484,7 @@ }, "packages/saas-sdk": { "name": "@codefly-dev/saas-sdk", - "version": "0.3.35", + "version": "0.3.36", "license": "Apache-2.0", "dependencies": { "@bufbuild/protobuf": "^2.11.0", @@ -15518,7 +15518,7 @@ }, "packages/saas-ui": { "name": "@codefly-dev/saas-ui", - "version": "0.12.15", + "version": "0.12.16", "license": "Apache-2.0", "dependencies": { "@opentelemetry/api": "1.9.1", @@ -15529,7 +15529,7 @@ }, "devDependencies": { "@bufbuild/protobuf": "^2.11.0", - "@codefly-dev/saas-sdk": "^0.3.35", + "@codefly-dev/saas-sdk": "^0.3.36", "@codefly-dev/ui": "^0.12.0", "@connectrpc/connect": "^2.1.1", "@connectrpc/connect-web": "^2.1.1", @@ -15546,7 +15546,7 @@ }, "peerDependencies": { "@bufbuild/protobuf": ">=2", - "@codefly-dev/saas-sdk": "^0.3.35", + "@codefly-dev/saas-sdk": "^0.3.36", "@codefly-dev/ui": "^0.12.0", "@connectrpc/connect": ">=2", "@connectrpc/connect-web": ">=2", diff --git a/module/services/frontend/code/package.json b/module/services/frontend/code/package.json index f3277d9e8..d1c52f009 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.35", + "@codefly-dev/saas-sdk": "0.3.36", "@codefly-dev/saas-ui": "0.12.8", "@codefly-dev/ui": "0.12.8", "@codefly/saas-plugin-manifest": "0.1.2", diff --git a/module/services/frontend/code/packages/codefly-ui/package.json b/module/services/frontend/code/packages/codefly-ui/package.json index af8fac509..aa55415be 100644 --- a/module/services/frontend/code/packages/codefly-ui/package.json +++ b/module/services/frontend/code/packages/codefly-ui/package.json @@ -1,6 +1,6 @@ { "name": "@codefly-dev/ui", - "version": "0.12.15", + "version": "0.12.16", "description": "The shared, versioned Codefly SaaS frontend kit: the plugin host and the tokens-as-data skin mechanism, standalone so the host and Module-Federation remotes import and dedupe one copy.", "repository": { "type": "git", diff --git a/module/services/frontend/code/packages/codefly-ui/src/__tests__/no-raw-type-primitives.test.ts b/module/services/frontend/code/packages/codefly-ui/src/__tests__/no-raw-type-primitives.test.ts index 5d4c3d36b..790d6254b 100644 --- a/module/services/frontend/code/packages/codefly-ui/src/__tests__/no-raw-type-primitives.test.ts +++ b/module/services/frontend/code/packages/codefly-ui/src/__tests__/no-raw-type-primitives.test.ts @@ -78,7 +78,7 @@ const SAAS_UI_BASELINE: Record = { // Lowered from 16 when the credential block moved into // `datasources/credential-mode.tsx`, which carries none: the shared copy // names its slots, so the debt left behind shrank rather than moving. - "datasources/connect-github-form.tsx": 11, + "datasources/connect-github-form.tsx": 10, "datasources/datasources-panel.tsx": 15, }; const saasUiDir = resolve(srcDir, "../../saas-ui/src"); diff --git a/module/services/frontend/code/packages/saas-sdk/generated/contract/catalog.codefly.json b/module/services/frontend/code/packages/saas-sdk/generated/contract/catalog.codefly.json index 363d105db..49780c1dc 100644 --- a/module/services/frontend/code/packages/saas-sdk/generated/contract/catalog.codefly.json +++ b/module/services/frontend/code/packages/saas-sdk/generated/contract/catalog.codefly.json @@ -10,7 +10,7 @@ "kind": "protobuf", "package": "saas.accounts.v1", "path": "contract.binpb", - "digest": "sha256:373a8f3a22b7968c36dd9faffb9be5ef64213b1de2a532f5e567007bd65caaa7", + "digest": "sha256:a899ecff5c0e74ea89f34a8c761e58c79b50c0a5d2f5538e8e70c252233a0dbf", "services": [ { "name": "APIKeyService", diff --git a/module/services/frontend/code/packages/saas-sdk/generated/contract/contract.binpb b/module/services/frontend/code/packages/saas-sdk/generated/contract/contract.binpb index 590c0a8eb..db3861fdb 100644 Binary files a/module/services/frontend/code/packages/saas-sdk/generated/contract/contract.binpb and b/module/services/frontend/code/packages/saas-sdk/generated/contract/contract.binpb differ 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 e907bd78a..9b172a3c4 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:373a8f3a22b7968c36dd9faffb9be5ef64213b1de2a532f5e567007bd65caaa7 + contract-digest: sha256:a899ecff5c0e74ea89f34a8c761e58c79b50c0a5d2f5538e8e70c252233a0dbf services: - APIKeyService - AccessibleScopeService diff --git a/module/services/frontend/code/packages/saas-sdk/generated/typescript/src/gen/saas/accounts/v1/datasource_pb.ts b/module/services/frontend/code/packages/saas-sdk/generated/typescript/src/gen/saas/accounts/v1/datasource_pb.ts index 951f0a7fe..11f366482 100644 --- a/module/services/frontend/code/packages/saas-sdk/generated/typescript/src/gen/saas/accounts/v1/datasource_pb.ts +++ b/module/services/frontend/code/packages/saas-sdk/generated/typescript/src/gen/saas/accounts/v1/datasource_pb.ts @@ -18,7 +18,7 @@ import type { JsonObject, Message } from "@bufbuild/protobuf"; * Describes the file saas/accounts/v1/datasource.proto. */ export const file_saas_accounts_v1_datasource: GenFile = /*@__PURE__*/ - fileDesc("CiFzYWFzL2FjY291bnRzL3YxL2RhdGFzb3VyY2UucHJvdG8SEHNhYXMuYWNjb3VudHMudjEiXgoWR2l0SHViRGF0YXNvdXJjZUNvbmZpZxIMCgRyZXBvGAEgASgJEg0KBXBhdGhzGAIgAygJEg4KBmJyYW5jaBgDIAEoCRIXCg9maWxlX2V4dGVuc2lvbnMYBCADKAki9AEKD0FwaU9BdXRoMkNvbmZpZxI2CgVncmFudBgEIAEoDjInLnNhYXMuYWNjb3VudHMudjEuQXBpT0F1dGgyQ29uZmlnLkdyYW50EhEKCXRva2VuX3VybBgBIAEoCRIRCgljbGllbnRfaWQYAiABKAkSDgoGc2NvcGVzGAMgAygJInMKBUdyYW50EhUKEUdSQU5UX1VOU1BFQ0lGSUVEEAASFwoTR1JBTlRfUkVGUkVTSF9UT0tFThABEhwKGEdSQU5UX0NMSUVOVF9DUkVERU5USUFMUxACEhwKGEdSQU5UX0FVVEhPUklaQVRJT05fQ09ERRADIuoBChNBcGlEYXRhc291cmNlQ29uZmlnEhAKCGJhc2VfdXJsGAEgASgJEhUKDXJlc291cmNlX3BhdGgYAiABKAkSPAoPY3JlZGVudGlhbF9raW5kGAMgASgOMiMuc2Fhcy5hY2NvdW50cy52MS5BcGlDcmVkZW50aWFsS2luZBIZChFjcmVkZW50aWFsX2hlYWRlchgEIAEoCRIeChZjcmVkZW50aWFsX3F1ZXJ5X3BhcmFtGAUgASgJEjEKBm9hdXRoMhgGIAEoCzIhLnNhYXMuYWNjb3VudHMudjEuQXBpT0F1dGgyQ29uZmlnIkEKF0NyYXdsZXJEYXRhc291cmNlQ29uZmlnEhMKC3NpdGVtYXBfdXJsGAEgASgJEhEKCW1heF9wYWdlcxgCIAEoDSKGAQoWVXBsb2FkRGF0YXNvdXJjZUNvbmZpZxIQCghlbmRwb2ludBgBIAEoCRIOCgZyZWdpb24YAiABKAkSDgoGYnVja2V0GAMgASgJEg4KBnByZWZpeBgEIAEoCRIVCg1hY2Nlc3Nfa2V5X2lkGAUgASgJEhMKC21heF9vYmplY3RzGAYgASgNIoMHCgpEYXRhc291cmNlEgoKAmlkGAEgASgJEg4KBm9yZ19pZBgCIAEoCRI2Cghwcm92aWRlchgDIAEoDjIkLnNhYXMuYWNjb3VudHMudjEuRGF0YXNvdXJjZVByb3ZpZGVyEjgKBmdpdGh1YhgFIAEoCzIoLnNhYXMuYWNjb3VudHMudjEuR2l0SHViRGF0YXNvdXJjZUNvbmZpZxIyCgZzdGF0dXMYBiABKA4yIi5zYWFzLmFjY291bnRzLnYxLkRhdGFzb3VyY2VTdGF0dXMSGgoSd2ViaG9va19jb25maWd1cmVkGAcgASgIEi4KCmNyZWF0ZWRfYXQYCCABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEi4KCnVwZGF0ZWRfYXQYCSABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEjIKDmxhc3Rfc3luY2VkX2F0GAogASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBIyCgNhcGkYCyABKAsyJS5zYWFzLmFjY291bnRzLnYxLkFwaURhdGFzb3VyY2VDb25maWcSOgoHY3Jhd2xlchgMIAEoCzIpLnNhYXMuYWNjb3VudHMudjEuQ3Jhd2xlckRhdGFzb3VyY2VDb25maWcSOAoGdXBsb2FkGA0gASgLMiguc2Fhcy5hY2NvdW50cy52MS5VcGxvYWREYXRhc291cmNlQ29uZmlnEhgKEGJvdW5kYXJ5X25vZGVfaWQYDiABKAkSNAoQbGFzdF9pbmdlc3RlZF9hdBgPIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXASHAoUbGFzdF9pbmdlc3RlZF9jb21taXQYECABKAkSFQoNc3RhdHVzX3JlYXNvbhgRIAEoCRIWCg5ib3VuZGFyeV9sYWJlbBgSIAEoCRISCgpjb25mb3JtYW50GBMgASgIEhcKD2NvbmZvcm1hbmNlX2dhcBgUIAEoCRI/Cg1saXZlX2RlbGl2ZXJ5GBUgASgOMiguc2Fhcy5hY2NvdW50cy52MS5EYXRhc291cmNlTGl2ZURlbGl2ZXJ5EjUKEnJlY29uY2lsZV9pbnRlcnZhbBgWIAEoCzIZLmdvb2dsZS5wcm90b2J1Zi5EdXJhdGlvbkoECAQQBVIRdGFyZ2V0X2NvbGxlY3Rpb24isgMKFkFkZEdpdEh1YlNvdXJjZVJlcXVlc3QSGAoGb3JnX2lkGAEgASgJQgi6SAVyA7ABARI7CgRyZXBvGAIgASgJQi26SCpyKBADGP8BMiFeW0EtWmEtejAtOV8uLV0rL1tBLVphLXowLTlfLi1dKyQSIAoFcGF0aHMYAyADKAlCEbpIDpIBCxBAIgdyBRABGIAEEhgKBmJyYW5jaBgEIAEoCUIIukgFcgMY/wESJAoQYm91bmRhcnlfbm9kZV9pZBgIIAEoCUIIukgFcgOwAQFIABImChBjb2xsZWN0aW9uX2xhYmVsGAkgASgJQgq6SAdyBRABGP8BSAASHgoMYWNjZXNzX3Rva2VuGAYgASgJQgi6SAVyAxiACBIgCg53ZWJob29rX3NlY3JldBgHIAEoCUIIukgFcgMYgAgSSQoPZmlsZV9leHRlbnNpb25zGAogAygJQjC6SC2SASoQICImciQQAhghMh5eXC5bQS1aYS16MC05XVtBLVphLXowLTkuXy1dKiRCEQoIYm91bmRhcnkSBbpIAggBSgQIBRAGUhF0YXJnZXRfY29sbGVjdGlvbiJLChdBZGRHaXRIdWJTb3VyY2VSZXNwb25zZRIwCgpkYXRhc291cmNlGAEgASgLMhwuc2Fhcy5hY2NvdW50cy52MS5EYXRhc291cmNlIsgEChBBZGRTb3VyY2VSZXF1ZXN0EhgKBm9yZ19pZBgBIAEoCUIIukgFcgOwAQESQgoIcHJvdmlkZXIYAiABKA4yJC5zYWFzLmFjY291bnRzLnYxLkRhdGFzb3VyY2VQcm92aWRlckIKukgHggEEEAEgABI6CgZnaXRodWIYBCABKAsyKC5zYWFzLmFjY291bnRzLnYxLkdpdEh1YkRhdGFzb3VyY2VDb25maWdIABI0CgNhcGkYBSABKAsyJS5zYWFzLmFjY291bnRzLnYxLkFwaURhdGFzb3VyY2VDb25maWdIABI8CgdjcmF3bGVyGAggASgLMikuc2Fhcy5hY2NvdW50cy52MS5DcmF3bGVyRGF0YXNvdXJjZUNvbmZpZ0gAEjoKBnVwbG9hZBgJIAEoCzIoLnNhYXMuYWNjb3VudHMudjEuVXBsb2FkRGF0YXNvdXJjZUNvbmZpZ0gAEiQKEGJvdW5kYXJ5X25vZGVfaWQYDCABKAlCCLpIBXIDsAEBSAESJgoQY29sbGVjdGlvbl9sYWJlbBgNIAEoCUIKukgHcgUQARj/AUgBEhwKCmNyZWRlbnRpYWwYBiABKAlCCLpIBXIDGIAgEiAKDndlYmhvb2tfc2VjcmV0GAcgASgJQgi6SAVyAxiACBImChRvYXV0aDJfY2xpZW50X3NlY3JldBgKIAEoCUIIukgFcgMYgAhCCAoGY29uZmlnQhEKCGJvdW5kYXJ5EgW6SAIIAUoECAMQBFIRdGFyZ2V0X2NvbGxlY3Rpb24iRQoRQWRkU291cmNlUmVzcG9uc2USMAoKZGF0YXNvdXJjZRgBIAEoCzIcLnNhYXMuYWNjb3VudHMudjEuRGF0YXNvdXJjZSKKAgoTVXBkYXRlU291cmNlUmVxdWVzdBIYCgZvcmdfaWQYASABKAlCCLpIBXIDsAEBEhQKAmlkGAIgASgJQgi6SAVyA7ABARIYCgZicmFuY2gYAyABKAlCCLpIBXIDGP8BEiAKBXBhdGhzGAQgAygJQhG6SA6SAQsQQCIHcgUQARiABBJJCg9maWxlX2V4dGVuc2lvbnMYBSADKAlCMLpILZIBKhAgIiZyJBACGCEyHl5cLltBLVphLXowLTldW0EtWmEtejAtOS5fLV0qJBI8CghleHBlY3RlZBgGIAEoCzIiLnNhYXMuYWNjb3VudHMudjEuU291cmNlRmV0Y2hTY29wZUIGukgDyAEBInkKEFNvdXJjZUZldGNoU2NvcGUSGAoGYnJhbmNoGAEgASgJQgi6SAVyAxj/ARIgCgVwYXRocxgCIAMoCUIRukgOkgELEEAiB3IFEAEYgAQSKQoPZmlsZV9leHRlbnNpb25zGAMgAygJQhC6SA2SAQoQICIGcgQQARghIlgKFFVwZGF0ZVNvdXJjZVJlc3BvbnNlEjAKCmRhdGFzb3VyY2UYASABKAsyHC5zYWFzLmFjY291bnRzLnYxLkRhdGFzb3VyY2USDgoGam9iX2lkGAIgASgJIloKFURhdGFzb3VyY2VDb25maWdGaWVsZBILCgNrZXkYASABKAkSFAoMZGlzcGxheV9uYW1lGAIgASgJEgwKBGhlbHAYAyABKAkSEAoIcmVxdWlyZWQYBCABKAgixAEKGURhdGFzb3VyY2VDb25uZWN0b3JCdWRnZXQSGgoSbWF4X2l0ZW1zX3Blcl9jYWxsGAEgASgNEhoKEm1heF9ieXRlc19wZXJfY2FsbBgCIAEoAxIWCg5tYXhfaXRlbV9ieXRlcxgDIAEoAxIdChVvcGVyYXRpb25zX3Blcl93aW5kb3cYBCABKA0SFgoOd2luZG93X3NlY29uZHMYBSABKAMSIAoYYmFja2dyb3VuZF9zaGFyZV9wZXJjZW50GAYgASgNIqoGChxEYXRhc291cmNlUHJvdmlkZXJEZXNjcmlwdG9yEjYKCHByb3ZpZGVyGAEgASgOMiQuc2Fhcy5hY2NvdW50cy52MS5EYXRhc291cmNlUHJvdmlkZXISFAoMZGlzcGxheV9uYW1lGAIgASgJEhMKC2Rlc2NyaXB0aW9uGAMgASgJEj4KDWNvbmZpZ19maWVsZHMYBCADKAsyJy5zYWFzLmFjY291bnRzLnYxLkRhdGFzb3VyY2VDb25maWdGaWVsZBIYChBzdXBwb3J0c193ZWJob29rGAUgASgIEkcKGnN1cHBvcnRlZF9jcmVkZW50aWFsX2tpbmRzGAYgAygOMiMuc2Fhcy5hY2NvdW50cy52MS5BcGlDcmVkZW50aWFsS2luZBIRCgljb25uZWN0b3IYByABKAkSQQoJaW50ZXJmYWNlGAggASgOMi4uc2Fhcy5hY2NvdW50cy52MS5EYXRhc291cmNlQ29ubmVjdG9ySW50ZXJmYWNlEkQKEGNyZWRlbnRpYWxfbW9kZXMYCSADKA4yKi5zYWFzLmFjY291bnRzLnYxLkRhdGFzb3VyY2VDcmVkZW50aWFsTW9kZRI/Cg1yZWFkZXJzX21vZGVsGAogASgOMiguc2Fhcy5hY2NvdW50cy52MS5EYXRhc291cmNlUmVhZGVyc01vZGVsEjsKBmJ1ZGdldBgLIAEoCzIrLnNhYXMuYWNjb3VudHMudjEuRGF0YXNvdXJjZUNvbm5lY3RvckJ1ZGdldBISCgpjb25mb3JtYW50GAwgASgIEhcKD2NvbmZvcm1hbmNlX2dhcBgNIAEoCRIbChNhY2NlcHRzX25ld19zb3VyY2VzGA4gASgIEiAKGGxpdmVfZGVsaXZlcnlfY29uZmlndXJlZBgPIAEoCBIaChJhY2NlcHRzX29wZXJhdGlvbnMYECABKAgSFgoOb3BlcmF0aW9uc19nYXAYESABKAkSGwoTb2F1dGhfYXV0aG9yaXplX3VybBgSIAEoCRIXCg9vYXV0aF90b2tlbl91cmwYEyABKAkSFAoMb2F1dGhfc2NvcGVzGBQgAygJIh0KG0dldERhdGFzb3VyY2VDYXRhbG9nUmVxdWVzdCJhChxHZXREYXRhc291cmNlQ2F0YWxvZ1Jlc3BvbnNlEkEKCXByb3ZpZGVycxgBIAMoCzIuLnNhYXMuYWNjb3VudHMudjEuRGF0YXNvdXJjZVByb3ZpZGVyRGVzY3JpcHRvciIuChJMaXN0U291cmNlc1JlcXVlc3QSGAoGb3JnX2lkGAEgASgJQgi6SAVyA7ABASJIChNMaXN0U291cmNlc1Jlc3BvbnNlEjEKC2RhdGFzb3VyY2VzGAEgAygLMhwuc2Fhcy5hY2NvdW50cy52MS5EYXRhc291cmNlIkIKEEdldFNvdXJjZVJlcXVlc3QSGAoGb3JnX2lkGAEgASgJQgi6SAVyA7ABARIUCgJpZBgCIAEoCUIIukgFcgOwAQEiRQoRR2V0U291cmNlUmVzcG9uc2USMAoKZGF0YXNvdXJjZRgBIAEoCzIcLnNhYXMuYWNjb3VudHMudjEuRGF0YXNvdXJjZSJ2ChFTeW5jU291cmNlUmVxdWVzdBIYCgZvcmdfaWQYASABKAlCCLpIBXIDsAEBEhQKAmlkGAIgASgJQgi6SAVyA7ABARIeCgxhY2Nlc3NfdG9rZW4YAyABKAlCCLpIBXIDGIAgEhEKCXJlY29ubmVjdBgEIAEoCCIkChJTeW5jU291cmNlUmVzcG9uc2USDgoGam9iX2lkGAEgASgJImoKFEdldFNvdXJjZVN5bmNSZXF1ZXN0EhgKBm9yZ19pZBgBIAEoCUIIukgFcgOwAQESGwoJc291cmNlX2lkGAIgASgJQgi6SAVyA7ABARIbCgZqb2JfaWQYAyABKAlCC7pICNgBAXIDsAEBIpUBChJTb3VyY2VTeW5jRGVsaXZlcnkSGAoGam9iX2lkGAEgASgJQgi6SAVyA7ABARItCgVzdGF0ZRgCIAEoDjIWLnNhYXMuam9icy52MS5Kb2JTdGF0ZUIGukgDyAEBEjYKCWV4ZWN1dGlvbhgDIAEoCzIjLnNhYXMuam9icy52MS5Kb2JFeGVjdXRpb25SZWZlcmVuY2UiiwEKEVNvdXJjZVN5bmNDaGFuZ2VzEg0KBWZpbGVzGAEgASgNEg0KBWFkZGVkGAIgASgNEhAKCG1vZGlmaWVkGAMgASgNEg8KB2RlbGV0ZWQYBCABKA0SEwoLc3BsaXRfa25vd24YBSABKAgSEAoIc25hcHNob3QYBiABKAgSDgoGY29tbWl0GAcgASgJIq0BChFTb3VyY2VTeW5jRmFpbHVyZRI5CgZyZWFzb24YASABKA4yKS5zYWFzLmFjY291bnRzLnYxLlNvdXJjZVN5bmNGYWlsdXJlUmVhc29uEgwKBGNvZGUYAiABKAkSDwoHbWVzc2FnZRgDIAEoCRIQCghyZXRyeWluZxgEIAEoCBIsCghyZXRyeV9hdBgFIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXAihAQKElNvdXJjZVN5bmNQcm9ncmVzcxIwCgVwaGFzZRgBIAEoDjIhLnNhYXMuYWNjb3VudHMudjEuU291cmNlU3luY1BoYXNlEjQKB3RyaWdnZXIYAiABKA4yIy5zYWFzLmFjY291bnRzLnYxLlNvdXJjZVN5bmNUcmlnZ2VyEi0KCXF1ZXVlZF9hdBgDIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXASLwoLZmV0Y2hpbmdfYXQYBCABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEi8KC2NvbXBpbGVkX2F0GAUgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBIxCg1oYW5kZWRfb2ZmX2F0GAYgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBIvCgtmaW5pc2hlZF9hdBgHIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXASNAoHY2hhbmdlcxgIIAEoCzIjLnNhYXMuYWNjb3VudHMudjEuU291cmNlU3luY0NoYW5nZXMSNAoHZmFpbHVyZRgJIAEoCzIjLnNhYXMuYWNjb3VudHMudjEuU291cmNlU3luY0ZhaWx1cmUSDwoHYXR0ZW1wdBgKIAEoDRIUCgxtYXhfYXR0ZW1wdHMYCyABKA0i0gEKFUdldFNvdXJjZVN5bmNSZXNwb25zZRIYCgZqb2JfaWQYASABKAlCCLpIBXIDsAEBEi0KBXN0YXRlGAIgASgOMhYuc2Fhcy5qb2JzLnYxLkpvYlN0YXRlQga6SAPIAQESOAoKZGVsaXZlcmllcxgDIAMoCzIkLnNhYXMuYWNjb3VudHMudjEuU291cmNlU3luY0RlbGl2ZXJ5EjYKCHByb2dyZXNzGAQgASgLMiQuc2Fhcy5hY2NvdW50cy52MS5Tb3VyY2VTeW5jUHJvZ3Jlc3MiRQoTRGVsZXRlU291cmNlUmVxdWVzdBIYCgZvcmdfaWQYASABKAlCCLpIBXIDsAEBEhQKAmlkGAIgASgJQgi6SAVyA7ABASIWChREZWxldGVTb3VyY2VSZXNwb25zZSJWChNHaXRIdWJBcHBSZXBvc2l0b3J5EgwKBHJlcG8YASABKAkSFgoOZGVmYXVsdF9icmFuY2gYAiABKAkSGQoRYWxyZWFkeV9jb25uZWN0ZWQYAyABKAgiNgoaQmVnaW5HaXRIdWJBcHBTZXR1cFJlcXVlc3QSGAoGb3JnX2lkGAEgASgJQgi6SAVyA7ABASJxChtCZWdpbkdpdEh1YkFwcFNldHVwUmVzcG9uc2USEwoLaW5zdGFsbF91cmwYASABKAkSDQoFc3RhdGUYAiABKAkSLgoKZXhwaXJlc19hdBgDIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXAinAEKHUNvbXBsZXRlR2l0SHViQXBwU2V0dXBSZXF1ZXN0EhgKBm9yZ19pZBgBIAEoCUIIukgFcgOwAQESGQoFc3RhdGUYAiABKAlCCrpIB3IFEAEY/wESLAoPaW5zdGFsbGF0aW9uX2lkGAMgASgJQhO6SBByDhABGCAyCF5bMC05XSskEhgKBGNvZGUYBCABKAlCCrpIB3IFEAEY/wEidgoeQ29tcGxldGVHaXRIdWJBcHBTZXR1cFJlc3BvbnNlEhcKD2luc3RhbGxhdGlvbl9pZBgBIAEoCRI7CgxyZXBvc2l0b3JpZXMYAiADKAsyJS5zYWFzLmFjY291bnRzLnYxLkdpdEh1YkFwcFJlcG9zaXRvcnkiUQofTWlncmF0ZUdpdEh1YlNvdXJjZVRvQXBwUmVxdWVzdBIYCgZvcmdfaWQYASABKAlCCLpIBXIDsAEBEhQKAmlkGAIgASgJQgi6SAVyA7ABASJUCiBNaWdyYXRlR2l0SHViU291cmNlVG9BcHBSZXNwb25zZRIwCgpkYXRhc291cmNlGAEgASgLMhwuc2Fhcy5hY2NvdW50cy52MS5EYXRhc291cmNlIsQBChVEYXRhc291cmNlQWNjb3VudExpbmsSCgoCaWQYASABKAkSDgoGb3JnX2lkGAIgASgJEg8KB3VzZXJfaWQYAyABKAkSEQoJY29ubmVjdG9yGAQgASgJEhsKE3Byb3ZpZGVyX2FjY291bnRfaWQYBSABKAkSHgoWcHJvdmlkZXJfYWNjb3VudF9sb2dpbhgGIAEoCRIuCgpjcmVhdGVkX2F0GAcgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcCK3AQoWRGF0YXNvdXJjZUdyb3VwQmluZGluZxIKCgJpZBgBIAEoCRIOCgZvcmdfaWQYAiABKAkSEQoJY29ubmVjdG9yGAMgASgJEhkKEXByb3ZpZGVyX2dyb3VwX2lkGAQgASgJEg8KB3RlYW1faWQYBSABKAkSEgoKY3JlYXRlZF9ieRgGIAEoCRIuCgpjcmVhdGVkX2F0GAcgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcCKUAgoYRGF0YXNvdXJjZVZlcmlmaWVkRG9tYWluEgoKAmlkGAEgASgJEg4KBm9yZ19pZBgCIAEoCRIOCgZkb21haW4YAyABKAkSOAoGc3RhdHVzGAQgASgOMiguc2Fhcy5hY2NvdW50cy52MS5EYXRhc291cmNlRG9tYWluU3RhdHVzEhcKD3R4dF9yZWNvcmRfbmFtZRgFIAEoCRIYChB0eHRfcmVjb3JkX3ZhbHVlGAYgASgJEi8KC3ZlcmlmaWVkX2F0GAcgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBIuCgpjcmVhdGVkX2F0GAggASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcCIzChdEYXRhc291cmNlRGlyZWN0b3J5VGVhbRIKCgJpZBgBIAEoCRIMCgRuYW1lGAIgASgJIqYBCiFCZWdpbkRhdGFzb3VyY2VBY2NvdW50TGlua1JlcXVlc3QSGAoGb3JnX2lkGAEgASgJQgi6SAVyA7ABARI0Cgljb25uZWN0b3IYAiABKAlCIbpIHnIcEAIYIDIWXlthLXpdW2EtejAtOS1dezEsMzF9JBIeCgxyZWRpcmVjdF91cmkYAyABKAlCCLpIBXIDGIAQEhEKCXNvdXJjZV9pZBgEIAEoCSJ6CiJCZWdpbkRhdGFzb3VyY2VBY2NvdW50TGlua1Jlc3BvbnNlEhUKDWF1dGhvcml6ZV91cmwYASABKAkSDQoFc3RhdGUYAiABKAkSLgoKZXhwaXJlc19hdBgDIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXAidQokQ29tcGxldGVEYXRhc291cmNlQWNjb3VudExpbmtSZXF1ZXN0EhgKBm9yZ19pZBgBIAEoCUIIukgFcgOwAQESGQoFc3RhdGUYAiABKAlCCrpIB3IFEAEYgEASGAoEY29kZRgDIAEoCUIKukgHcgUQARj/ASJeCiVDb21wbGV0ZURhdGFzb3VyY2VBY2NvdW50TGlua1Jlc3BvbnNlEjUKBGxpbmsYASABKAsyJy5zYWFzLmFjY291bnRzLnYxLkRhdGFzb3VyY2VBY2NvdW50TGluayI/CiNMaXN0TXlEYXRhc291cmNlQWNjb3VudExpbmtzUmVxdWVzdBIYCgZvcmdfaWQYASABKAlCCLpIBXIDsAEBIl4KJExpc3RNeURhdGFzb3VyY2VBY2NvdW50TGlua3NSZXNwb25zZRI2CgVsaW5rcxgBIAMoCzInLnNhYXMuYWNjb3VudHMudjEuRGF0YXNvdXJjZUFjY291bnRMaW5rIlQKIkRlbGV0ZURhdGFzb3VyY2VBY2NvdW50TGlua1JlcXVlc3QSGAoGb3JnX2lkGAEgASgJQgi6SAVyA7ABARIUCgJpZBgCIAEoCUIIukgFcgOwAQEiJQojRGVsZXRlRGF0YXNvdXJjZUFjY291bnRMaW5rUmVzcG9uc2UiOQodR2V0RGF0YXNvdXJjZURpcmVjdG9yeVJlcXVlc3QSGAoGb3JnX2lkGAEgASgJQgi6SAVyA7ABASKLAgoeR2V0RGF0YXNvdXJjZURpcmVjdG9yeVJlc3BvbnNlEjYKBWxpbmtzGAEgAygLMicuc2Fhcy5hY2NvdW50cy52MS5EYXRhc291cmNlQWNjb3VudExpbmsSOgoIYmluZGluZ3MYAiADKAsyKC5zYWFzLmFjY291bnRzLnYxLkRhdGFzb3VyY2VHcm91cEJpbmRpbmcSOwoHZG9tYWlucxgDIAMoCzIqLnNhYXMuYWNjb3VudHMudjEuRGF0YXNvdXJjZVZlcmlmaWVkRG9tYWluEjgKBXRlYW1zGAQgAygLMikuc2Fhcy5hY2NvdW50cy52MS5EYXRhc291cmNlRGlyZWN0b3J5VGVhbSKuAQoaQmluZERhdGFzb3VyY2VHcm91cFJlcXVlc3QSGAoGb3JnX2lkGAEgASgJQgi6SAVyA7ABARI0Cgljb25uZWN0b3IYAiABKAlCIbpIHnIcEAIYIDIWXlthLXpdW2EtejAtOS1dezEsMzF9JBIlChFwcm92aWRlcl9ncm91cF9pZBgDIAEoCUIKukgHcgUQARj/ARIZCgd0ZWFtX2lkGAQgASgJQgi6SAVyA7ABASJYChtCaW5kRGF0YXNvdXJjZUdyb3VwUmVzcG9uc2USOQoHYmluZGluZxgBIAEoCzIoLnNhYXMuYWNjb3VudHMudjEuRGF0YXNvdXJjZUdyb3VwQmluZGluZyJOChxVbmJpbmREYXRhc291cmNlR3JvdXBSZXF1ZXN0EhgKBm9yZ19pZBgBIAEoCUIIukgFcgOwAQESFAoCaWQYAiABKAlCCLpIBXIDsAEBIh8KHVVuYmluZERhdGFzb3VyY2VHcm91cFJlc3BvbnNlIlQKHENsYWltRGF0YXNvdXJjZURvbWFpblJlcXVlc3QSGAoGb3JnX2lkGAEgASgJQgi6SAVyA7ABARIaCgZkb21haW4YAiABKAlCCrpIB3IFEAMY/QEiWwodQ2xhaW1EYXRhc291cmNlRG9tYWluUmVzcG9uc2USOgoGZG9tYWluGAEgASgLMiouc2Fhcy5hY2NvdW50cy52MS5EYXRhc291cmNlVmVyaWZpZWREb21haW4iTwodVmVyaWZ5RGF0YXNvdXJjZURvbWFpblJlcXVlc3QSGAoGb3JnX2lkGAEgASgJQgi6SAVyA7ABARIUCgJpZBgCIAEoCUIIukgFcgOwAQEiXAoeVmVyaWZ5RGF0YXNvdXJjZURvbWFpblJlc3BvbnNlEjoKBmRvbWFpbhgBIAEoCzIqLnNhYXMuYWNjb3VudHMudjEuRGF0YXNvdXJjZVZlcmlmaWVkRG9tYWluIk8KHURlbGV0ZURhdGFzb3VyY2VEb21haW5SZXF1ZXN0EhgKBm9yZ19pZBgBIAEoCUIIukgFcgOwAQESFAoCaWQYAiABKAlCCLpIBXIDsAEBIiAKHkRlbGV0ZURhdGFzb3VyY2VEb21haW5SZXNwb25zZSKeAgoQU291cmNlRGVsZWdhdGlvbhIKCgJpZBgBIAEoCRIRCglzb3VyY2VfaWQYAiABKAkSFAoMcHJpbmNpcGFsX2lkGAMgASgJEg4KBm1vZHVsZRgEIAEoCRIPCgdiaW5kaW5nGAUgASgJEi4KCmNyZWF0ZWRfYXQYBiABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEi4KCnJldm9rZWRfYXQYByABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEkAKCnJldm9jYXRpb24YCCABKA4yLC5zYWFzLmFjY291bnRzLnYxLlNvdXJjZURlbGVnYXRpb25SZXZvY2F0aW9uEhIKCnJldm9rZWRfYnkYCSABKAkigQEKHExpc3RTb3VyY2VEZWxlZ2F0aW9uc1JlcXVlc3QSGAoGb3JnX2lkGAEgASgJQgi6SAVyA7ABARIgCglzb3VyY2VfaWQYAiABKAlCCLpIBXIDsAEBSACIAQESFwoPaW5jbHVkZV9yZXZva2VkGAMgASgIQgwKCl9zb3VyY2VfaWQiWAodTGlzdFNvdXJjZURlbGVnYXRpb25zUmVzcG9uc2USNwoLZGVsZWdhdGlvbnMYASADKAsyIi5zYWFzLmFjY291bnRzLnYxLlNvdXJjZURlbGVnYXRpb24iTwodUmV2b2tlU291cmNlRGVsZWdhdGlvblJlcXVlc3QSGAoGb3JnX2lkGAEgASgJQgi6SAVyA7ABARIUCgJpZBgCIAEoCUIIukgFcgOwAQEiWAoeUmV2b2tlU291cmNlRGVsZWdhdGlvblJlc3BvbnNlEjYKCmRlbGVnYXRpb24YASABKAsyIi5zYWFzLmFjY291bnRzLnYxLlNvdXJjZURlbGVnYXRpb24iMgoPVHJpZ2dlcklkZW50aXR5Eg4KBnNvdXJjZRgBIAEoCRIPCgdiaW5kaW5nGAIgASgJIoQBChdEZWNsYXJlZFRyaWdnZXJTY2hlZHVsZRIMCgRjcm9uGAEgASgJEhAKCHRpbWV6b25lGAIgASgJEhIKCm1pc3NlZF9ydW4YAyABKAkSDwoHb3ZlcmxhcBgEIAEoCRIQCghkZWFkbGluZRgFIAEoAxISCgppbnB1dF9qc29uGAYgASgJIucCChpUcmlnZ2VyRGVsZWdhdGlvblJlZmVyZW5jZRIRCglyZWZlcmVuY2UYASABKAkSFQoNZGVsZWdhdGlvbl9pZBgCIAEoCRIOCgZtb2R1bGUYAyABKAkSDwoHYmluZGluZxgEIAEoCRIQCghhdWRpZW5jZRgFIAEoCRIOCgZ0ZW5hbnQYBiABKAkSGgoSb3duZXJfcHJpbmNpcGFsX2lkGAcgASgJEg4KBnNjb3BlcxgIIAMoCRIOCgZhY3RpdmUYCSABKAgSLgoKY3JlYXRlZF9hdBgKIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXASLgoKcmV2b2tlZF9hdBgLIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXASQAoKcmV2b2NhdGlvbhgMIAEoDjIsLnNhYXMuYWNjb3VudHMudjEuU291cmNlRGVsZWdhdGlvblJldm9jYXRpb24ivgEKE1RyaWdnZXJDaGlsZENvbnRleHQSFQoNZGVsZWdhdGlvbl9pZBgBIAEoCRIOCgZtb2R1bGUYAiABKAkSDwoHYmluZGluZxgDIAEoCRIQCghhdWRpZW5jZRgEIAEoCRIOCgZzY29wZXMYBSADKAkSDgoGbG9va3VwGAYgASgIEi0KCW1pbnRlZF9hdBgHIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXASDgoGcmVjb3JkGAggASgJIs0CChhTb3VyY2VUcmlnZ2VyRGVjbGFyYXRpb24SDwoHcmVsZWFzZRgBIAEoCRIRCglvcGVyYXRpb24YAiABKAkSOwoIc2NoZWR1bGUYAyABKAsyKS5zYWFzLmFjY291bnRzLnYxLkRlY2xhcmVkVHJpZ2dlclNjaGVkdWxlEjMKCGlkZW50aXR5GAQgASgLMiEuc2Fhcy5hY2NvdW50cy52MS5UcmlnZ2VySWRlbnRpdHkSDgoGZGlnZXN0GAUgASgJEjYKEmxhc3Rfb2NjdXJyZW5jZV9hdBgGIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXASNgoSbmV4dF9vY2N1cnJlbmNlX2F0GAcgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBIbChN1bmRlY2xhcmFibGVfcmVhc29uGAggASgJIlYKHURlbGVnYXRlU291cmNlVHJpZ2dlcnNSZXF1ZXN0EhgKBm9yZ19pZBgBIAEoCUIIukgFcgOwAQESGwoJc291cmNlX2lkGAIgASgJQgi6SAVyA7ABASJjCh5EZWxlZ2F0ZVNvdXJjZVRyaWdnZXJzUmVzcG9uc2USQQoLZGVsZWdhdGlvbnMYASADKAsyLC5zYWFzLmFjY291bnRzLnYxLlRyaWdnZXJEZWxlZ2F0aW9uUmVmZXJlbmNlIlsKIkdldFNvdXJjZVRyaWdnZXJEZWNsYXJhdGlvblJlcXVlc3QSGAoGb3JnX2lkGAEgASgJQgi6SAVyA7ABARIbCglzb3VyY2VfaWQYAiABKAlCCLpIBXIDsAEBIuIBCiNHZXRTb3VyY2VUcmlnZ2VyRGVjbGFyYXRpb25SZXNwb25zZRI/CgtkZWNsYXJhdGlvbhgBIAEoCzIqLnNhYXMuYWNjb3VudHMudjEuU291cmNlVHJpZ2dlckRlY2xhcmF0aW9uEkEKC2RlbGVnYXRpb25zGAIgAygLMiwuc2Fhcy5hY2NvdW50cy52MS5UcmlnZ2VyRGVsZWdhdGlvblJlZmVyZW5jZRI3CghjaGlsZHJlbhgDIAMoCzIlLnNhYXMuYWNjb3VudHMudjEuVHJpZ2dlckNoaWxkQ29udGV4dCJuChZSZWNvbmNpbGVTb3VyY2VSZXF1ZXN0EhgKBm9yZ19pZBgBIAEoCUIIukgFcgOwAQESGwoJc291cmNlX2lkGAIgASgJQgi6SAVyA7ABARIdCgllZmZlY3RfaWQYAyABKAlCCrpIB3IFEAEYgAEiTAoXUmVjb25jaWxlU291cmNlUmVzcG9uc2USEQoJZWZmZWN0X2lkGAEgASgJEg4KBnN0YXR1cxgCIAEoCRIOCgZqb2JfaWQYAyABKAkiPQocTG9va3VwUmVjb25jaWxlU291cmNlUmVxdWVzdBIdCgllZmZlY3RfaWQYASABKAlCCrpIB3IFEAEYgAEi8QIKD1NvdXJjZU9wZXJhdGlvbhIMCgRuYW1lGAEgASgJEhMKC2Rlc2NyaXB0aW9uGAIgASgJEg4KBm1ldGhvZBgDIAEoCRIMCgRwYXRoGAQgASgJEg0KBXF1ZXJ5GAUgAygJEi0KDGlucHV0X3NjaGVtYRgGIAEoCzIXLmdvb2dsZS5wcm90b2J1Zi5TdHJ1Y3QSLgoNb3V0cHV0X3NjaGVtYRgHIAEoCzIXLmdvb2dsZS5wcm90b2J1Zi5TdHJ1Y3QSOAoGZWZmZWN0GAggASgOMiguc2Fhcy5hY2NvdW50cy52MS5Tb3VyY2VPcGVyYXRpb24uRWZmZWN0EhgKEG1heF9vdXRwdXRfYnl0ZXMYCSABKA0SDgoGZGlnZXN0GAogASgJIksKBkVmZmVjdBIWChJFRkZFQ1RfVU5TUEVDSUZJRUQQABIUChBFRkZFQ1RfUkVBRF9PTkxZEAESEwoPRUZGRUNUX01VVEFUSU9OEAIijgEKHkRlY2xhcmVTb3VyY2VPcGVyYXRpb25zUmVxdWVzdBIYCgZvcmdfaWQYASABKAlCCLpIBXIDsAEBEhsKCXNvdXJjZV9pZBgCIAEoCUIIukgFcgOwAQESNQoKb3BlcmF0aW9ucxgDIAMoCzIhLnNhYXMuYWNjb3VudHMudjEuU291cmNlT3BlcmF0aW9uIlgKH0RlY2xhcmVTb3VyY2VPcGVyYXRpb25zUmVzcG9uc2USNQoKb3BlcmF0aW9ucxgBIAMoCzIhLnNhYXMuYWNjb3VudHMudjEuU291cmNlT3BlcmF0aW9uIlQKG0xpc3RTb3VyY2VPcGVyYXRpb25zUmVxdWVzdBIYCgZvcmdfaWQYASABKAlCCLpIBXIDsAEBEhsKCXNvdXJjZV9pZBgCIAEoCUIIukgFcgOwAQEiVQocTGlzdFNvdXJjZU9wZXJhdGlvbnNSZXNwb25zZRI1CgpvcGVyYXRpb25zGAEgAygLMiEuc2Fhcy5hY2NvdW50cy52MS5Tb3VyY2VPcGVyYXRpb24iwwEKHEludm9rZVNvdXJjZU9wZXJhdGlvblJlcXVlc3QSGAoGb3JnX2lkGAEgASgJQgi6SAVyA7ABARIbCglzb3VyY2VfaWQYAiABKAlCCLpIBXIDsAEBEjAKCW9wZXJhdGlvbhgDIAEoCUIdukgachgyFl5bYS16XVthLXowLTlfXXswLDYzfSQSHQoKaW5wdXRfanNvbhgEIAEoCUIJukgGcgQogIAEEhsKCWVmZmVjdF9pZBgFIAEoCUIIukgFcgMogAEifwoWU291cmNlT3BlcmF0aW9uUmVjZWlwdBIRCgllZmZlY3RfaWQYASABKAkSFAoMY29tbWl0dGVkX2F0GAIgASgJEg4KBnN0YXR1cxgDIAEoCRIXCg9wcm92aWRlcl9zdGF0dXMYBCABKA0SEwoLb3V0cHV0X2pzb24YBSABKAkibwodSW52b2tlU291cmNlT3BlcmF0aW9uUmVzcG9uc2USEwoLb3V0cHV0X2pzb24YASABKAkSOQoHcmVjZWlwdBgCIAEoCzIoLnNhYXMuYWNjb3VudHMudjEuU291cmNlT3BlcmF0aW9uUmVjZWlwdCJDCiJMb29rdXBJbnZva2VTb3VyY2VPcGVyYXRpb25SZXF1ZXN0Eh0KCWVmZmVjdF9pZBgBIAEoCUIKukgHcgUQASiAASJ5CiNQcnVuZVNvdXJjZU9wZXJhdGlvblJlY2VpcHRzUmVxdWVzdBIYCgZvcmdfaWQYASABKAlCCLpIBXIDsAEBEhsKCXNvdXJjZV9pZBgCIAEoCUIIukgFcgOwAQESGwoJZWZmZWN0X2lkGAMgASgJQgi6SAVyAyiAASJbCiRQcnVuZVNvdXJjZU9wZXJhdGlvblJlY2VpcHRzUmVzcG9uc2USEQoJZWZmZWN0X2lkGAEgASgJEhAKCHJlY2VpcHRzGAIgASgDEg4KBnN0YXR1cxgDIAEoCSJKCilMb29rdXBQcnVuZVNvdXJjZU9wZXJhdGlvblJlY2VpcHRzUmVxdWVzdBIdCgllZmZlY3RfaWQYASABKAlCCrpIB3IFEAEogAEqtwEKEkRhdGFzb3VyY2VQcm92aWRlchIjCh9EQVRBU09VUkNFX1BST1ZJREVSX1VOU1BFQ0lGSUVEEAASHgoaREFUQVNPVVJDRV9QUk9WSURFUl9HSVRIVUIQARIbChdEQVRBU09VUkNFX1BST1ZJREVSX0FQSRACEh8KG0RBVEFTT1VSQ0VfUFJPVklERVJfQ1JBV0xFUhADEh4KGkRBVEFTT1VSQ0VfUFJPVklERVJfVVBMT0FEEAQqkQEKEERhdGFzb3VyY2VTdGF0dXMSIQodREFUQVNPVVJDRV9TVEFUVVNfVU5TUEVDSUZJRUQQABIcChhEQVRBU09VUkNFX1NUQVRVU19BQ1RJVkUQARIcChhEQVRBU09VUkNFX1NUQVRVU19QQVVTRUQQAhIeChpEQVRBU09VUkNFX1NUQVRVU19ERUdSQURFRBADKrwBChZEYXRhc291cmNlTGl2ZURlbGl2ZXJ5EigKJERBVEFTT1VSQ0VfTElWRV9ERUxJVkVSWV9VTlNQRUNJRklFRBAAEiEKHURBVEFTT1VSQ0VfTElWRV9ERUxJVkVSWV9OT05FEAESKwonREFUQVNPVVJDRV9MSVZFX0RFTElWRVJZX1NPVVJDRV9XRUJIT09LEAISKAokREFUQVNPVVJDRV9MSVZFX0RFTElWRVJZX0FQUF9XRUJIT09LEAMq1gEKEUFwaUNyZWRlbnRpYWxLaW5kEiMKH0FQSV9DUkVERU5USUFMX0tJTkRfVU5TUEVDSUZJRUQQABIeChpBUElfQ1JFREVOVElBTF9LSU5EX0JFQVJFUhABEh0KGUFQSV9DUkVERU5USUFMX0tJTkRfQkFTSUMQAhIeChpBUElfQ1JFREVOVElBTF9LSU5EX0hFQURFUhADEh0KGUFQSV9DUkVERU5USUFMX0tJTkRfUVVFUlkQBBIeChpBUElfQ1JFREVOVElBTF9LSU5EX09BVVRIMhAFKqYCChxEYXRhc291cmNlQ29ubmVjdG9ySW50ZXJmYWNlEi4KKkRBVEFTT1VSQ0VfQ09OTkVDVE9SX0lOVEVSRkFDRV9VTlNQRUNJRklFRBAAEigKJERBVEFTT1VSQ0VfQ09OTkVDVE9SX0lOVEVSRkFDRV9GSUxFUxABEigKJERBVEFTT1VSQ0VfQ09OTkVDVE9SX0lOVEVSRkFDRV9QQUdFUxACEioKJkRBVEFTT1VSQ0VfQ09OTkVDVE9SX0lOVEVSRkFDRV9SRUNPUkRTEAMSKwonREFUQVNPVVJDRV9DT05ORUNUT1JfSU5URVJGQUNFX01FU1NBR0VTEAQSKQolREFUQVNPVVJDRV9DT05ORUNUT1JfSU5URVJGQUNFX0VWRU5UUxAFKuwBChhEYXRhc291cmNlQ3JlZGVudGlhbE1vZGUSKgomREFUQVNPVVJDRV9DUkVERU5USUFMX01PREVfVU5TUEVDSUZJRUQQABIjCh9EQVRBU09VUkNFX0NSRURFTlRJQUxfTU9ERV9OT05FEAESJgoiREFUQVNPVVJDRV9DUkVERU5USUFMX01PREVfT1JHX0FQUBACEikKJURBVEFTT1VSQ0VfQ1JFREVOVElBTF9NT0RFX1VTRVJfT0FVVEgQAxIsCihEQVRBU09VUkNFX0NSRURFTlRJQUxfTU9ERV9TVEFUSUNfU0VDUkVUEAQqlwEKFkRhdGFzb3VyY2VSZWFkZXJzTW9kZWwSKAokREFUQVNPVVJDRV9SRUFERVJTX01PREVMX1VOU1BFQ0lGSUVEEAASKgomREFUQVNPVVJDRV9SRUFERVJTX01PREVMX1NPVVJDRV9TQ09QRUQQARInCiNEQVRBU09VUkNFX1JFQURFUlNfTU9ERUxfVFJBTlNMQVRFRBACKu4BCg9Tb3VyY2VTeW5jUGhhc2USIQodU09VUkNFX1NZTkNfUEhBU0VfVU5TUEVDSUZJRUQQABIcChhTT1VSQ0VfU1lOQ19QSEFTRV9RVUVVRUQQARIeChpTT1VSQ0VfU1lOQ19QSEFTRV9GRVRDSElORxACEh4KGlNPVVJDRV9TWU5DX1BIQVNFX0NPTVBJTEVEEAMSIAocU09VUkNFX1NZTkNfUEhBU0VfSEFOREVEX09GRhAEEhoKFlNPVVJDRV9TWU5DX1BIQVNFX0RPTkUQBRIcChhTT1VSQ0VfU1lOQ19QSEFTRV9GQUlMRUQQBiqmAwoXU291cmNlU3luY0ZhaWx1cmVSZWFzb24SKgomU09VUkNFX1NZTkNfRkFJTFVSRV9SRUFTT05fVU5TUEVDSUZJRUQQABIrCidTT1VSQ0VfU1lOQ19GQUlMVVJFX1JFQVNPTl9SQVRFX0xJTUlURUQQARIpCiVTT1VSQ0VfU1lOQ19GQUlMVVJFX1JFQVNPTl9DUkVERU5USUFMEAISLAooU09VUkNFX1NZTkNfRkFJTFVSRV9SRUFTT05fQUNDRVNTX0RFTklFRBADEigKJFNPVVJDRV9TWU5DX0ZBSUxVUkVfUkVBU09OX05PVF9GT1VORBAEEigKJFNPVVJDRV9TWU5DX0ZBSUxVUkVfUkVBU09OX1RPT19MQVJHRRAFEi8KK1NPVVJDRV9TWU5DX0ZBSUxVUkVfUkVBU09OX0hPU1RfVU5BVkFJTEFCTEUQBhIkCiBTT1VSQ0VfU1lOQ19GQUlMVVJFX1JFQVNPTl9PVEhFUhAHEi4KKlNPVVJDRV9TWU5DX0ZBSUxVUkVfUkVBU09OX0RFTElWRVJZX0ZBSUxFRBAIKsIBChFTb3VyY2VTeW5jVHJpZ2dlchIjCh9TT1VSQ0VfU1lOQ19UUklHR0VSX1VOU1BFQ0lGSUVEEAASHgoaU09VUkNFX1NZTkNfVFJJR0dFUl9NQU5VQUwQARIhCh1TT1VSQ0VfU1lOQ19UUklHR0VSX1NDSEVEVUxFRBACEh8KG1NPVVJDRV9TWU5DX1RSSUdHRVJfV0VCSE9PSxADEiQKIFNPVVJDRV9TWU5DX1RSSUdHRVJfUkVDT05GSUdVUkVEEAQqjwEKFkRhdGFzb3VyY2VEb21haW5TdGF0dXMSKAokREFUQVNPVVJDRV9ET01BSU5fU1RBVFVTX1VOU1BFQ0lGSUVEEAASJAogREFUQVNPVVJDRV9ET01BSU5fU1RBVFVTX1BFTkRJTkcQARIlCiFEQVRBU09VUkNFX0RPTUFJTl9TVEFUVVNfVkVSSUZJRUQQAiqVAwoaU291cmNlRGVsZWdhdGlvblJldm9jYXRpb24SLAooU09VUkNFX0RFTEVHQVRJT05fUkVWT0NBVElPTl9VTlNQRUNJRklFRBAAEigKJFNPVVJDRV9ERUxFR0FUSU9OX1JFVk9DQVRJT05fUkVWT0tFRBABEikKJVNPVVJDRV9ERUxFR0FUSU9OX1JFVk9DQVRJT05fUkVQTEFDRUQQAhIvCitTT1VSQ0VfREVMRUdBVElPTl9SRVZPQ0FUSU9OX1NPVVJDRV9ERUxFVEVEEAMSLworU09VUkNFX0RFTEVHQVRJT05fUkVWT0NBVElPTl9NRU1CRVJfUkVNT1ZFRBAEEjAKLFNPVVJDRV9ERUxFR0FUSU9OX1JFVk9DQVRJT05fUEVSTUlTU0lPTl9MT1NUEAUSLgoqU09VUkNFX0RFTEVHQVRJT05fUkVWT0NBVElPTl9VU0VSX0lOQUNUSVZFEAYSMAosU09VUkNFX0RFTEVHQVRJT05fUkVWT0NBVElPTl9CSU5ESU5HX0NIQU5HRUQQBzKxOQoRRGF0YXNvdXJjZVNlcnZpY2USuQMKHFBydW5lU291cmNlT3BlcmF0aW9uUmVjZWlwdHMSNS5zYWFzLmFjY291bnRzLnYxLlBydW5lU291cmNlT3BlcmF0aW9uUmVjZWlwdHNSZXF1ZXN0GjYuc2Fhcy5hY2NvdW50cy52MS5QcnVuZVNvdXJjZU9wZXJhdGlvblJlY2VpcHRzUmVzcG9uc2UiqQLC8xhsCAIQAxoRZGF0YXNvdXJjZTppbnZva2UiEWRhdGFzb3VyY2U6aW52b2tlKg8KCXNvdXJjZV9pZBAEGAMwATojCh9zYWFzLmRhdGFzb3VyY2UucmVjZWlwdHMucHJ1bmVkEAJAA0gEUANYA2AB2vMYtAEKAgg8EgMI2AQYBSICCAIqC1VOQVZBSUxBQkxFKhJSRVNPVVJDRV9FWEhBVVNURUQqB0FCT1JURUQyD3NhYXMtZGF0YXNvdXJjZUpGL3NhYXMuYWNjb3VudHMudjEuRGF0YXNvdXJjZVNlcnZpY2UvTG9va3VwUHJ1bmVTb3VyY2VPcGVyYXRpb25SZWNlaXB0c1CAIFiAIGABchgKBnNvdXJjZRIGaW52b2tlEgRyZWFkGAES5gEKIkxvb2t1cFBydW5lU291cmNlT3BlcmF0aW9uUmVjZWlwdHMSOy5zYWFzLmFjY291bnRzLnYxLkxvb2t1cFBydW5lU291cmNlT3BlcmF0aW9uUmVjZWlwdHNSZXF1ZXN0GjYuc2Fhcy5hY2NvdW50cy52MS5QcnVuZVNvdXJjZU9wZXJhdGlvblJlY2VpcHRzUmVzcG9uc2UiS8LzGEcIAhADGg9kYXRhc291cmNlOnJlYWQiD2RhdGFzb3VyY2U6cmVhZCoPCgllZmZlY3RfaWQQBBgDMAE6AhABQAFIA1ADWANgARL8AwoVSW52b2tlU291cmNlT3BlcmF0aW9uEi4uc2Fhcy5hY2NvdW50cy52MS5JbnZva2VTb3VyY2VPcGVyYXRpb25SZXF1ZXN0Gi8uc2Fhcy5hY2NvdW50cy52MS5JbnZva2VTb3VyY2VPcGVyYXRpb25SZXNwb25zZSKBA8LzGG4IAhADGhFkYXRhc291cmNlOmludm9rZSIRZGF0YXNvdXJjZTppbnZva2UqDwoJc291cmNlX2lkEAQYAzABOiUKIXNhYXMuZGF0YXNvdXJjZS5vcGVyYXRpb24uaW52b2tlZBAEQANIBFAEWARgAdrzGK8BCgIIPBIDCNgEGAUiAggCKgtVTkFWQUlMQUJMRSoSUkVTT1VSQ0VfRVhIQVVTVEVEKgdBQk9SVEVEMg9zYWFzLWRhdGFzb3VyY2VKPy9zYWFzLmFjY291bnRzLnYxLkRhdGFzb3VyY2VTZXJ2aWNlL0xvb2t1cEludm9rZVNvdXJjZU9wZXJhdGlvblCAgARYgIBAYAFyGAoGc291cmNlEgZpbnZva2USBHJlYWQYAYLT5JMCVToBKiJQL3YxL29yZ2FuaXphdGlvbnMve29yZ19pZH0vZGF0YXNvdXJjZXMve3NvdXJjZV9pZH0vb3BlcmF0aW9ucy97b3BlcmF0aW9ufTppbnZva2UShwIKG0xvb2t1cEludm9rZVNvdXJjZU9wZXJhdGlvbhI0LnNhYXMuYWNjb3VudHMudjEuTG9va3VwSW52b2tlU291cmNlT3BlcmF0aW9uUmVxdWVzdBovLnNhYXMuYWNjb3VudHMudjEuSW52b2tlU291cmNlT3BlcmF0aW9uUmVzcG9uc2UigAHC8xhHCAIQAxoPZGF0YXNvdXJjZTpyZWFkIg9kYXRhc291cmNlOnJlYWQqDwoJZWZmZWN0X2lkEAQYAzABOgIQAUABSANQA1gEYAGC0+STAi8SLS92MS9kYXRhc291cmNlLW9wZXJhdGlvbi1yZWNlaXB0cy97ZWZmZWN0X2lkfRKFAgoUTGlzdFNvdXJjZU9wZXJhdGlvbnMSLS5zYWFzLmFjY291bnRzLnYxLkxpc3RTb3VyY2VPcGVyYXRpb25zUmVxdWVzdBouLnNhYXMuYWNjb3VudHMudjEuTGlzdFNvdXJjZU9wZXJhdGlvbnNSZXNwb25zZSKNAcLzGEQIAhADGg9kYXRhc291cmNlOnJlYWQiD2RhdGFzb3VyY2U6cmVhZCoMCgZvcmdfaWQQAhgBMAE6AhABQAFIA1ADWANgAYLT5JMCPxI9L3YxL29yZ2FuaXphdGlvbnMve29yZ19pZH0vZGF0YXNvdXJjZXMve3NvdXJjZV9pZH0vb3BlcmF0aW9ucxKTAgoXRGVjbGFyZVNvdXJjZU9wZXJhdGlvbnMSMC5zYWFzLmFjY291bnRzLnYxLkRlY2xhcmVTb3VyY2VPcGVyYXRpb25zUmVxdWVzdBoxLnNhYXMuYWNjb3VudHMudjEuRGVjbGFyZVNvdXJjZU9wZXJhdGlvbnNSZXNwb25zZSKSAcLzGEYIAhAEKgwKBm9yZ19pZBACGAEwATomCiJzYWFzLmRhdGFzb3VyY2Uub3BlcmF0aW9uLmRlY2xhcmVkEAJAAUgEUANYA2ABgtPkkwJCOgEqIj0vdjEvb3JnYW5pemF0aW9ucy97b3JnX2lkfS9kYXRhc291cmNlcy97c291cmNlX2lkfS9vcGVyYXRpb25zEqwBCg9BZGRHaXRIdWJTb3VyY2USKC5zYWFzLmFjY291bnRzLnYxLkFkZEdpdEh1YlNvdXJjZVJlcXVlc3QaKS5zYWFzLmFjY291bnRzLnYxLkFkZEdpdEh1YlNvdXJjZVJlc3BvbnNlIkTC8xhACAIQBCoMCgZvcmdfaWQQAhgBMAE6IAocc2Fhcy5kYXRhc291cmNlLnNvdXJjZS5hZGRlZBACQAFIBFAEWANgARKaAQoJQWRkU291cmNlEiIuc2Fhcy5hY2NvdW50cy52MS5BZGRTb3VyY2VSZXF1ZXN0GiMuc2Fhcy5hY2NvdW50cy52MS5BZGRTb3VyY2VSZXNwb25zZSJEwvMYQAgCEAQqDAoGb3JnX2lkEAIYATABOiAKHHNhYXMuZGF0YXNvdXJjZS5zb3VyY2UuYWRkZWQQAkABSARQBFgDYAESpQEKDFVwZGF0ZVNvdXJjZRIlLnNhYXMuYWNjb3VudHMudjEuVXBkYXRlU291cmNlUmVxdWVzdBomLnNhYXMuYWNjb3VudHMudjEuVXBkYXRlU291cmNlUmVzcG9uc2UiRsLzGEIIAhAEKgwKBm9yZ19pZBACGAEwAToiCh5zYWFzLmRhdGFzb3VyY2Uuc291cmNlLnVwZGF0ZWQQAkABSARQAlgDYAESjwEKFEdldERhdGFzb3VyY2VDYXRhbG9nEi0uc2Fhcy5hY2NvdW50cy52MS5HZXREYXRhc291cmNlQ2F0YWxvZ1JlcXVlc3QaLi5zYWFzLmFjY291bnRzLnYxLkdldERhdGFzb3VyY2VDYXRhbG9nUmVzcG9uc2UiGMLzGBQIAhABMAE6AhABQAFIA1ACWAJgARKCAQoLTGlzdFNvdXJjZXMSJC5zYWFzLmFjY291bnRzLnYxLkxpc3RTb3VyY2VzUmVxdWVzdBolLnNhYXMuYWNjb3VudHMudjEuTGlzdFNvdXJjZXNSZXNwb25zZSImwvMYIggCEAMqDAoGb3JnX2lkEAIYATABOgIQAUABSANQAlgDYAESfAoJR2V0U291cmNlEiIuc2Fhcy5hY2NvdW50cy52MS5HZXRTb3VyY2VSZXF1ZXN0GiMuc2Fhcy5hY2NvdW50cy52MS5HZXRTb3VyY2VSZXNwb25zZSImwvMYIggCEAMqDAoGb3JnX2lkEAIYATABOgIQAUABSANQAlgDYAESngEKClN5bmNTb3VyY2USIy5zYWFzLmFjY291bnRzLnYxLlN5bmNTb3VyY2VSZXF1ZXN0GiQuc2Fhcy5hY2NvdW50cy52MS5TeW5jU291cmNlUmVzcG9uc2UiRcLzGEEIAhAEKgwKBm9yZ19pZBACGAEwATohCh1zYWFzLmRhdGFzb3VyY2Uuc291cmNlLnN5bmNlZBACQAFIBFAEWAJgARKIAQoNR2V0U291cmNlU3luYxImLnNhYXMuYWNjb3VudHMudjEuR2V0U291cmNlU3luY1JlcXVlc3QaJy5zYWFzLmFjY291bnRzLnYxLkdldFNvdXJjZVN5bmNSZXNwb25zZSImwvMYIggCEAQqDAoGb3JnX2lkEAIYATABOgIQAUABSANQAlgDYAESpQEKDERlbGV0ZVNvdXJjZRIlLnNhYXMuYWNjb3VudHMudjEuRGVsZXRlU291cmNlUmVxdWVzdBomLnNhYXMuYWNjb3VudHMudjEuRGVsZXRlU291cmNlUmVzcG9uc2UiRsLzGEIIAhAEKgwKBm9yZ19pZBACGAEwAToiCh5zYWFzLmRhdGFzb3VyY2Uuc291cmNlLnJlbW92ZWQQAkABSARQAlgCYAESxAEKE0JlZ2luR2l0SHViQXBwU2V0dXASLC5zYWFzLmFjY291bnRzLnYxLkJlZ2luR2l0SHViQXBwU2V0dXBSZXF1ZXN0Gi0uc2Fhcy5hY2NvdW50cy52MS5CZWdpbkdpdEh1YkFwcFNldHVwUmVzcG9uc2UiUMLzGEwIAhAEKgwKBm9yZ19pZBACGAEwATosCihzYWFzLmRhdGFzb3VyY2UuZ2l0aHViX2FwcC5zZXR1cF9zdGFydGVkEAJAAUgEUAJYBGABEs8BChZDb21wbGV0ZUdpdEh1YkFwcFNldHVwEi8uc2Fhcy5hY2NvdW50cy52MS5Db21wbGV0ZUdpdEh1YkFwcFNldHVwUmVxdWVzdBowLnNhYXMuYWNjb3VudHMudjEuQ29tcGxldGVHaXRIdWJBcHBTZXR1cFJlc3BvbnNlIlLC8xhOCAIQBCoMCgZvcmdfaWQQAhgBMAE6Lgoqc2Fhcy5kYXRhc291cmNlLmdpdGh1Yl9hcHAuc2V0dXBfY29tcGxldGVkEAJAAUgEUARYA2ABEs0BChhNaWdyYXRlR2l0SHViU291cmNlVG9BcHASMS5zYWFzLmFjY291bnRzLnYxLk1pZ3JhdGVHaXRIdWJTb3VyY2VUb0FwcFJlcXVlc3QaMi5zYWFzLmFjY291bnRzLnYxLk1pZ3JhdGVHaXRIdWJTb3VyY2VUb0FwcFJlc3BvbnNlIkrC8xhGCAIQBCoMCgZvcmdfaWQQAhgBMAE6Jgoic2Fhcy5kYXRhc291cmNlLmNyZWRlbnRpYWwudXBkYXRlZBACQAFIBFACWANgARLVAQoaQmVnaW5EYXRhc291cmNlQWNjb3VudExpbmsSMy5zYWFzLmFjY291bnRzLnYxLkJlZ2luRGF0YXNvdXJjZUFjY291bnRMaW5rUmVxdWVzdBo0LnNhYXMuYWNjb3VudHMudjEuQmVnaW5EYXRhc291cmNlQWNjb3VudExpbmtSZXNwb25zZSJMwvMYSAgCEAMqDAoGb3JnX2lkEAIYATABOigKJHNhYXMuZGF0YXNvdXJjZS5hY2NvdW50X2xpbmtfc3RhcnRlZBACQAFIBFACWARgARLYAQodQ29tcGxldGVEYXRhc291cmNlQWNjb3VudExpbmsSNi5zYWFzLmFjY291bnRzLnYxLkNvbXBsZXRlRGF0YXNvdXJjZUFjY291bnRMaW5rUmVxdWVzdBo3LnNhYXMuYWNjb3VudHMudjEuQ29tcGxldGVEYXRhc291cmNlQWNjb3VudExpbmtSZXNwb25zZSJGwvMYQggCEAMqDAoGb3JnX2lkEAIYATABOiIKHnNhYXMuZGF0YXNvdXJjZS5hY2NvdW50X2xpbmtlZBACQAFIBFAEWANgARK1AQocTGlzdE15RGF0YXNvdXJjZUFjY291bnRMaW5rcxI1LnNhYXMuYWNjb3VudHMudjEuTGlzdE15RGF0YXNvdXJjZUFjY291bnRMaW5rc1JlcXVlc3QaNi5zYWFzLmFjY291bnRzLnYxLkxpc3RNeURhdGFzb3VyY2VBY2NvdW50TGlua3NSZXNwb25zZSImwvMYIggCEAMqDAoGb3JnX2lkEAIYATABOgIQAUABSANQAlgDYAES1AEKG0RlbGV0ZURhdGFzb3VyY2VBY2NvdW50TGluaxI0LnNhYXMuYWNjb3VudHMudjEuRGVsZXRlRGF0YXNvdXJjZUFjY291bnRMaW5rUmVxdWVzdBo1LnNhYXMuYWNjb3VudHMudjEuRGVsZXRlRGF0YXNvdXJjZUFjY291bnRMaW5rUmVzcG9uc2UiSMLzGEQIAhADKgwKBm9yZ19pZBACGAEwATokCiBzYWFzLmRhdGFzb3VyY2UuYWNjb3VudF91bmxpbmtlZBACQAFIBFACWANgARKjAQoWR2V0RGF0YXNvdXJjZURpcmVjdG9yeRIvLnNhYXMuYWNjb3VudHMudjEuR2V0RGF0YXNvdXJjZURpcmVjdG9yeVJlcXVlc3QaMC5zYWFzLmFjY291bnRzLnYxLkdldERhdGFzb3VyY2VEaXJlY3RvcnlSZXNwb25zZSImwvMYIggCEAQqDAoGb3JnX2lkEAIYATABOgIQAUABSANQAlgDYAEStwEKE0JpbmREYXRhc291cmNlR3JvdXASLC5zYWFzLmFjY291bnRzLnYxLkJpbmREYXRhc291cmNlR3JvdXBSZXF1ZXN0Gi0uc2Fhcy5hY2NvdW50cy52MS5CaW5kRGF0YXNvdXJjZUdyb3VwUmVzcG9uc2UiQ8LzGD8IAhAEKgwKBm9yZ19pZBACGAEwATofChtzYWFzLmRhdGFzb3VyY2UuZ3JvdXBfYm91bmQQAkABSARQAlgDYAESvwEKFVVuYmluZERhdGFzb3VyY2VHcm91cBIuLnNhYXMuYWNjb3VudHMudjEuVW5iaW5kRGF0YXNvdXJjZUdyb3VwUmVxdWVzdBovLnNhYXMuYWNjb3VudHMudjEuVW5iaW5kRGF0YXNvdXJjZUdyb3VwUmVzcG9uc2UiRcLzGEEIAhAEKgwKBm9yZ19pZBACGAEwATohCh1zYWFzLmRhdGFzb3VyY2UuZ3JvdXBfdW5ib3VuZBACQAFIBFACWANgARLAAQoVQ2xhaW1EYXRhc291cmNlRG9tYWluEi4uc2Fhcy5hY2NvdW50cy52MS5DbGFpbURhdGFzb3VyY2VEb21haW5SZXF1ZXN0Gi8uc2Fhcy5hY2NvdW50cy52MS5DbGFpbURhdGFzb3VyY2VEb21haW5SZXNwb25zZSJGwvMYQggCEAQqDAoGb3JnX2lkEAIYATABOiIKHnNhYXMuZGF0YXNvdXJjZS5kb21haW5fY2xhaW1lZBACQAFIBFACWANgARLEAQoWVmVyaWZ5RGF0YXNvdXJjZURvbWFpbhIvLnNhYXMuYWNjb3VudHMudjEuVmVyaWZ5RGF0YXNvdXJjZURvbWFpblJlcXVlc3QaMC5zYWFzLmFjY291bnRzLnYxLlZlcmlmeURhdGFzb3VyY2VEb21haW5SZXNwb25zZSJHwvMYQwgCEAQqDAoGb3JnX2lkEAIYATABOiMKH3NhYXMuZGF0YXNvdXJjZS5kb21haW5fdmVyaWZpZWQQAkABSARQAlgDYAESwwEKFkRlbGV0ZURhdGFzb3VyY2VEb21haW4SLy5zYWFzLmFjY291bnRzLnYxLkRlbGV0ZURhdGFzb3VyY2VEb21haW5SZXF1ZXN0GjAuc2Fhcy5hY2NvdW50cy52MS5EZWxldGVEYXRhc291cmNlRG9tYWluUmVzcG9uc2UiRsLzGEIIAhAEKgwKBm9yZ19pZBACGAEwAToiCh5zYWFzLmRhdGFzb3VyY2UuZG9tYWluX3JlbW92ZWQQAkABSARQAlgDYAESoAEKFUxpc3RTb3VyY2VEZWxlZ2F0aW9ucxIuLnNhYXMuYWNjb3VudHMudjEuTGlzdFNvdXJjZURlbGVnYXRpb25zUmVxdWVzdBovLnNhYXMuYWNjb3VudHMudjEuTGlzdFNvdXJjZURlbGVnYXRpb25zUmVzcG9uc2UiJsLzGCIIAhAEKgwKBm9yZ19pZBACGAEwAToCEAFAAUgDUAJYA2ABEscBChZSZXZva2VTb3VyY2VEZWxlZ2F0aW9uEi8uc2Fhcy5hY2NvdW50cy52MS5SZXZva2VTb3VyY2VEZWxlZ2F0aW9uUmVxdWVzdBowLnNhYXMuYWNjb3VudHMudjEuUmV2b2tlU291cmNlRGVsZWdhdGlvblJlc3BvbnNlIkrC8xhGCAIQBCoMCgZvcmdfaWQQAhgBMAE6Jgoic2Fhcy5kYXRhc291cmNlLmRlbGVnYXRpb24ucmV2b2tlZBACQAFIBFACWANgARLrAQoWRGVsZWdhdGVTb3VyY2VUcmlnZ2VycxIvLnNhYXMuYWNjb3VudHMudjEuRGVsZWdhdGVTb3VyY2VUcmlnZ2Vyc1JlcXVlc3QaMC5zYWFzLmFjY291bnRzLnYxLkRlbGVnYXRlU291cmNlVHJpZ2dlcnNSZXNwb25zZSJuwvMYaggCEAQqDAoGb3JnX2lkEAIYATABOkoKInNhYXMuZGF0YXNvdXJjZS5kZWxlZ2F0aW9uLmNyZWF0ZWQKInNhYXMuZGF0YXNvdXJjZS5kZWxlZ2F0aW9uLnJldm9rZWQQAkABSARQAlgDYAESsgEKG0dldFNvdXJjZVRyaWdnZXJEZWNsYXJhdGlvbhI0LnNhYXMuYWNjb3VudHMudjEuR2V0U291cmNlVHJpZ2dlckRlY2xhcmF0aW9uUmVxdWVzdBo1LnNhYXMuYWNjb3VudHMudjEuR2V0U291cmNlVHJpZ2dlckRlY2xhcmF0aW9uUmVzcG9uc2UiJsLzGCIIAhAEKgwKBm9yZ19pZBACGAEwAToCEAFAAUgDUAJYA2ABEoMDCg9SZWNvbmNpbGVTb3VyY2USKC5zYWFzLmFjY291bnRzLnYxLlJlY29uY2lsZVNvdXJjZVJlcXVlc3QaKS5zYWFzLmFjY291bnRzLnYxLlJlY29uY2lsZVNvdXJjZVJlc3BvbnNlIpoCwvMYaggCEAMaEWRhdGFzb3VyY2U6aW52b2tlIhFkYXRhc291cmNlOmludm9rZSoPCglzb3VyY2VfaWQQBBgDMAE6IQodc2Fhcy5kYXRhc291cmNlLnNvdXJjZS5zeW5jZWQQAkADSARQAlgCYAHa8xinAQoCCDwSAwjYBBgFIgIIAioLVU5BVkFJTEFCTEUqElJFU09VUkNFX0VYSEFVU1RFRCoHQUJPUlRFRDIPc2Fhcy1kYXRhc291cmNlSjkvc2Fhcy5hY2NvdW50cy52MS5EYXRhc291cmNlU2VydmljZS9Mb29rdXBSZWNvbmNpbGVTb3VyY2VQgCBYgCBgAXIYCgZzb3VyY2USBmludm9rZRIEcmVhZBgBEr8BChVMb29rdXBSZWNvbmNpbGVTb3VyY2USLi5zYWFzLmFjY291bnRzLnYxLkxvb2t1cFJlY29uY2lsZVNvdXJjZVJlcXVlc3QaKS5zYWFzLmFjY291bnRzLnYxLlJlY29uY2lsZVNvdXJjZVJlc3BvbnNlIkvC8xhHCAIQAxoPZGF0YXNvdXJjZTpyZWFkIg9kYXRhc291cmNlOnJlYWQqDwoJZWZmZWN0X2lkEAQYAzABOgIQAUABSANQAlgCYAFiBnByb3RvMw", [file_buf_validate_validate, file_codefly_runnable_v0_options, file_google_api_annotations, file_google_protobuf_duration, file_google_protobuf_struct, file_google_protobuf_timestamp, file_saas_jobs_v1_jobs, file_saas_policy_v1_options]); + fileDesc("CiFzYWFzL2FjY291bnRzL3YxL2RhdGFzb3VyY2UucHJvdG8SEHNhYXMuYWNjb3VudHMudjEiXgoWR2l0SHViRGF0YXNvdXJjZUNvbmZpZxIMCgRyZXBvGAEgASgJEg0KBXBhdGhzGAIgAygJEg4KBmJyYW5jaBgDIAEoCRIXCg9maWxlX2V4dGVuc2lvbnMYBCADKAki9AEKD0FwaU9BdXRoMkNvbmZpZxI2CgVncmFudBgEIAEoDjInLnNhYXMuYWNjb3VudHMudjEuQXBpT0F1dGgyQ29uZmlnLkdyYW50EhEKCXRva2VuX3VybBgBIAEoCRIRCgljbGllbnRfaWQYAiABKAkSDgoGc2NvcGVzGAMgAygJInMKBUdyYW50EhUKEUdSQU5UX1VOU1BFQ0lGSUVEEAASFwoTR1JBTlRfUkVGUkVTSF9UT0tFThABEhwKGEdSQU5UX0NMSUVOVF9DUkVERU5USUFMUxACEhwKGEdSQU5UX0FVVEhPUklaQVRJT05fQ09ERRADIuoBChNBcGlEYXRhc291cmNlQ29uZmlnEhAKCGJhc2VfdXJsGAEgASgJEhUKDXJlc291cmNlX3BhdGgYAiABKAkSPAoPY3JlZGVudGlhbF9raW5kGAMgASgOMiMuc2Fhcy5hY2NvdW50cy52MS5BcGlDcmVkZW50aWFsS2luZBIZChFjcmVkZW50aWFsX2hlYWRlchgEIAEoCRIeChZjcmVkZW50aWFsX3F1ZXJ5X3BhcmFtGAUgASgJEjEKBm9hdXRoMhgGIAEoCzIhLnNhYXMuYWNjb3VudHMudjEuQXBpT0F1dGgyQ29uZmlnIkEKF0NyYXdsZXJEYXRhc291cmNlQ29uZmlnEhMKC3NpdGVtYXBfdXJsGAEgASgJEhEKCW1heF9wYWdlcxgCIAEoDSKrAgoWVXBsb2FkRGF0YXNvdXJjZUNvbmZpZxIQCghlbmRwb2ludBgBIAEoCRIOCgZyZWdpb24YAiABKAkSDgoGYnVja2V0GAMgASgJEg4KBnByZWZpeBgEIAEoCRIVCg1hY2Nlc3Nfa2V5X2lkGAUgASgJEhMKC21heF9vYmplY3RzGAYgASgNEisKEGluY2x1ZGVfcGF0dGVybnMYByADKAlCEbpIDpIBCxAQIgdyBRABGIACEisKEGV4Y2x1ZGVfcGF0dGVybnMYCCADKAlCEbpIDpIBCxAQIgdyBRABGIACEkkKD2ZpbGVfZXh0ZW5zaW9ucxgJIAMoCUIwukgtkgEqECAiJnIkEAIYITIeXlwuW0EtWmEtejAtOV1bQS1aYS16MC05Ll8tXSokIoMHCgpEYXRhc291cmNlEgoKAmlkGAEgASgJEg4KBm9yZ19pZBgCIAEoCRI2Cghwcm92aWRlchgDIAEoDjIkLnNhYXMuYWNjb3VudHMudjEuRGF0YXNvdXJjZVByb3ZpZGVyEjgKBmdpdGh1YhgFIAEoCzIoLnNhYXMuYWNjb3VudHMudjEuR2l0SHViRGF0YXNvdXJjZUNvbmZpZxIyCgZzdGF0dXMYBiABKA4yIi5zYWFzLmFjY291bnRzLnYxLkRhdGFzb3VyY2VTdGF0dXMSGgoSd2ViaG9va19jb25maWd1cmVkGAcgASgIEi4KCmNyZWF0ZWRfYXQYCCABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEi4KCnVwZGF0ZWRfYXQYCSABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEjIKDmxhc3Rfc3luY2VkX2F0GAogASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBIyCgNhcGkYCyABKAsyJS5zYWFzLmFjY291bnRzLnYxLkFwaURhdGFzb3VyY2VDb25maWcSOgoHY3Jhd2xlchgMIAEoCzIpLnNhYXMuYWNjb3VudHMudjEuQ3Jhd2xlckRhdGFzb3VyY2VDb25maWcSOAoGdXBsb2FkGA0gASgLMiguc2Fhcy5hY2NvdW50cy52MS5VcGxvYWREYXRhc291cmNlQ29uZmlnEhgKEGJvdW5kYXJ5X25vZGVfaWQYDiABKAkSNAoQbGFzdF9pbmdlc3RlZF9hdBgPIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXASHAoUbGFzdF9pbmdlc3RlZF9jb21taXQYECABKAkSFQoNc3RhdHVzX3JlYXNvbhgRIAEoCRIWCg5ib3VuZGFyeV9sYWJlbBgSIAEoCRISCgpjb25mb3JtYW50GBMgASgIEhcKD2NvbmZvcm1hbmNlX2dhcBgUIAEoCRI/Cg1saXZlX2RlbGl2ZXJ5GBUgASgOMiguc2Fhcy5hY2NvdW50cy52MS5EYXRhc291cmNlTGl2ZURlbGl2ZXJ5EjUKEnJlY29uY2lsZV9pbnRlcnZhbBgWIAEoCzIZLmdvb2dsZS5wcm90b2J1Zi5EdXJhdGlvbkoECAQQBVIRdGFyZ2V0X2NvbGxlY3Rpb24isgMKFkFkZEdpdEh1YlNvdXJjZVJlcXVlc3QSGAoGb3JnX2lkGAEgASgJQgi6SAVyA7ABARI7CgRyZXBvGAIgASgJQi26SCpyKBADGP8BMiFeW0EtWmEtejAtOV8uLV0rL1tBLVphLXowLTlfLi1dKyQSIAoFcGF0aHMYAyADKAlCEbpIDpIBCxBAIgdyBRABGIAEEhgKBmJyYW5jaBgEIAEoCUIIukgFcgMY/wESJAoQYm91bmRhcnlfbm9kZV9pZBgIIAEoCUIIukgFcgOwAQFIABImChBjb2xsZWN0aW9uX2xhYmVsGAkgASgJQgq6SAdyBRABGP8BSAASHgoMYWNjZXNzX3Rva2VuGAYgASgJQgi6SAVyAxiACBIgCg53ZWJob29rX3NlY3JldBgHIAEoCUIIukgFcgMYgAgSSQoPZmlsZV9leHRlbnNpb25zGAogAygJQjC6SC2SASoQICImciQQAhghMh5eXC5bQS1aYS16MC05XVtBLVphLXowLTkuXy1dKiRCEQoIYm91bmRhcnkSBbpIAggBSgQIBRAGUhF0YXJnZXRfY29sbGVjdGlvbiJLChdBZGRHaXRIdWJTb3VyY2VSZXNwb25zZRIwCgpkYXRhc291cmNlGAEgASgLMhwuc2Fhcy5hY2NvdW50cy52MS5EYXRhc291cmNlIsgEChBBZGRTb3VyY2VSZXF1ZXN0EhgKBm9yZ19pZBgBIAEoCUIIukgFcgOwAQESQgoIcHJvdmlkZXIYAiABKA4yJC5zYWFzLmFjY291bnRzLnYxLkRhdGFzb3VyY2VQcm92aWRlckIKukgHggEEEAEgABI6CgZnaXRodWIYBCABKAsyKC5zYWFzLmFjY291bnRzLnYxLkdpdEh1YkRhdGFzb3VyY2VDb25maWdIABI0CgNhcGkYBSABKAsyJS5zYWFzLmFjY291bnRzLnYxLkFwaURhdGFzb3VyY2VDb25maWdIABI8CgdjcmF3bGVyGAggASgLMikuc2Fhcy5hY2NvdW50cy52MS5DcmF3bGVyRGF0YXNvdXJjZUNvbmZpZ0gAEjoKBnVwbG9hZBgJIAEoCzIoLnNhYXMuYWNjb3VudHMudjEuVXBsb2FkRGF0YXNvdXJjZUNvbmZpZ0gAEiQKEGJvdW5kYXJ5X25vZGVfaWQYDCABKAlCCLpIBXIDsAEBSAESJgoQY29sbGVjdGlvbl9sYWJlbBgNIAEoCUIKukgHcgUQARj/AUgBEhwKCmNyZWRlbnRpYWwYBiABKAlCCLpIBXIDGIAgEiAKDndlYmhvb2tfc2VjcmV0GAcgASgJQgi6SAVyAxiACBImChRvYXV0aDJfY2xpZW50X3NlY3JldBgKIAEoCUIIukgFcgMYgAhCCAoGY29uZmlnQhEKCGJvdW5kYXJ5EgW6SAIIAUoECAMQBFIRdGFyZ2V0X2NvbGxlY3Rpb24iRQoRQWRkU291cmNlUmVzcG9uc2USMAoKZGF0YXNvdXJjZRgBIAEoCzIcLnNhYXMuYWNjb3VudHMudjEuRGF0YXNvdXJjZSLkAgoTVXBkYXRlU291cmNlUmVxdWVzdBIYCgZvcmdfaWQYASABKAlCCLpIBXIDsAEBEhQKAmlkGAIgASgJQgi6SAVyA7ABARIYCgZicmFuY2gYAyABKAlCCLpIBXIDGP8BEiAKBXBhdGhzGAQgAygJQhG6SA6SAQsQQCIHcgUQARiABBJJCg9maWxlX2V4dGVuc2lvbnMYBSADKAlCMLpILZIBKhAgIiZyJBACGCEyHl5cLltBLVphLXowLTldW0EtWmEtejAtOS5fLV0qJBI8CghleHBlY3RlZBgGIAEoCzIiLnNhYXMuYWNjb3VudHMudjEuU291cmNlRmV0Y2hTY29wZUIGukgDyAEBEisKEGluY2x1ZGVfcGF0dGVybnMYByADKAlCEbpIDpIBCxAQIgdyBRABGIACEisKEGV4Y2x1ZGVfcGF0dGVybnMYCCADKAlCEbpIDpIBCxAQIgdyBRABGIACIs8BChBTb3VyY2VGZXRjaFNjb3BlEhgKBmJyYW5jaBgBIAEoCUIIukgFcgMY/wESIAoFcGF0aHMYAiADKAlCEbpIDpIBCxBAIgdyBRABGIAEEikKD2ZpbGVfZXh0ZW5zaW9ucxgDIAMoCUIQukgNkgEKECAiBnIEEAEYIRIpChBpbmNsdWRlX3BhdHRlcm5zGAQgAygJQg+6SAySAQkQECIFcgMYgAISKQoQZXhjbHVkZV9wYXR0ZXJucxgFIAMoCUIPukgMkgEJEBAiBXIDGIACIlgKFFVwZGF0ZVNvdXJjZVJlc3BvbnNlEjAKCmRhdGFzb3VyY2UYASABKAsyHC5zYWFzLmFjY291bnRzLnYxLkRhdGFzb3VyY2USDgoGam9iX2lkGAIgASgJIloKFURhdGFzb3VyY2VDb25maWdGaWVsZBILCgNrZXkYASABKAkSFAoMZGlzcGxheV9uYW1lGAIgASgJEgwKBGhlbHAYAyABKAkSEAoIcmVxdWlyZWQYBCABKAgixAEKGURhdGFzb3VyY2VDb25uZWN0b3JCdWRnZXQSGgoSbWF4X2l0ZW1zX3Blcl9jYWxsGAEgASgNEhoKEm1heF9ieXRlc19wZXJfY2FsbBgCIAEoAxIWCg5tYXhfaXRlbV9ieXRlcxgDIAEoAxIdChVvcGVyYXRpb25zX3Blcl93aW5kb3cYBCABKA0SFgoOd2luZG93X3NlY29uZHMYBSABKAMSIAoYYmFja2dyb3VuZF9zaGFyZV9wZXJjZW50GAYgASgNIqoGChxEYXRhc291cmNlUHJvdmlkZXJEZXNjcmlwdG9yEjYKCHByb3ZpZGVyGAEgASgOMiQuc2Fhcy5hY2NvdW50cy52MS5EYXRhc291cmNlUHJvdmlkZXISFAoMZGlzcGxheV9uYW1lGAIgASgJEhMKC2Rlc2NyaXB0aW9uGAMgASgJEj4KDWNvbmZpZ19maWVsZHMYBCADKAsyJy5zYWFzLmFjY291bnRzLnYxLkRhdGFzb3VyY2VDb25maWdGaWVsZBIYChBzdXBwb3J0c193ZWJob29rGAUgASgIEkcKGnN1cHBvcnRlZF9jcmVkZW50aWFsX2tpbmRzGAYgAygOMiMuc2Fhcy5hY2NvdW50cy52MS5BcGlDcmVkZW50aWFsS2luZBIRCgljb25uZWN0b3IYByABKAkSQQoJaW50ZXJmYWNlGAggASgOMi4uc2Fhcy5hY2NvdW50cy52MS5EYXRhc291cmNlQ29ubmVjdG9ySW50ZXJmYWNlEkQKEGNyZWRlbnRpYWxfbW9kZXMYCSADKA4yKi5zYWFzLmFjY291bnRzLnYxLkRhdGFzb3VyY2VDcmVkZW50aWFsTW9kZRI/Cg1yZWFkZXJzX21vZGVsGAogASgOMiguc2Fhcy5hY2NvdW50cy52MS5EYXRhc291cmNlUmVhZGVyc01vZGVsEjsKBmJ1ZGdldBgLIAEoCzIrLnNhYXMuYWNjb3VudHMudjEuRGF0YXNvdXJjZUNvbm5lY3RvckJ1ZGdldBISCgpjb25mb3JtYW50GAwgASgIEhcKD2NvbmZvcm1hbmNlX2dhcBgNIAEoCRIbChNhY2NlcHRzX25ld19zb3VyY2VzGA4gASgIEiAKGGxpdmVfZGVsaXZlcnlfY29uZmlndXJlZBgPIAEoCBIaChJhY2NlcHRzX29wZXJhdGlvbnMYECABKAgSFgoOb3BlcmF0aW9uc19nYXAYESABKAkSGwoTb2F1dGhfYXV0aG9yaXplX3VybBgSIAEoCRIXCg9vYXV0aF90b2tlbl91cmwYEyABKAkSFAoMb2F1dGhfc2NvcGVzGBQgAygJIh0KG0dldERhdGFzb3VyY2VDYXRhbG9nUmVxdWVzdCJhChxHZXREYXRhc291cmNlQ2F0YWxvZ1Jlc3BvbnNlEkEKCXByb3ZpZGVycxgBIAMoCzIuLnNhYXMuYWNjb3VudHMudjEuRGF0YXNvdXJjZVByb3ZpZGVyRGVzY3JpcHRvciIuChJMaXN0U291cmNlc1JlcXVlc3QSGAoGb3JnX2lkGAEgASgJQgi6SAVyA7ABASJIChNMaXN0U291cmNlc1Jlc3BvbnNlEjEKC2RhdGFzb3VyY2VzGAEgAygLMhwuc2Fhcy5hY2NvdW50cy52MS5EYXRhc291cmNlIkIKEEdldFNvdXJjZVJlcXVlc3QSGAoGb3JnX2lkGAEgASgJQgi6SAVyA7ABARIUCgJpZBgCIAEoCUIIukgFcgOwAQEiRQoRR2V0U291cmNlUmVzcG9uc2USMAoKZGF0YXNvdXJjZRgBIAEoCzIcLnNhYXMuYWNjb3VudHMudjEuRGF0YXNvdXJjZSJ2ChFTeW5jU291cmNlUmVxdWVzdBIYCgZvcmdfaWQYASABKAlCCLpIBXIDsAEBEhQKAmlkGAIgASgJQgi6SAVyA7ABARIeCgxhY2Nlc3NfdG9rZW4YAyABKAlCCLpIBXIDGIAgEhEKCXJlY29ubmVjdBgEIAEoCCIkChJTeW5jU291cmNlUmVzcG9uc2USDgoGam9iX2lkGAEgASgJImoKFEdldFNvdXJjZVN5bmNSZXF1ZXN0EhgKBm9yZ19pZBgBIAEoCUIIukgFcgOwAQESGwoJc291cmNlX2lkGAIgASgJQgi6SAVyA7ABARIbCgZqb2JfaWQYAyABKAlCC7pICNgBAXIDsAEBIpUBChJTb3VyY2VTeW5jRGVsaXZlcnkSGAoGam9iX2lkGAEgASgJQgi6SAVyA7ABARItCgVzdGF0ZRgCIAEoDjIWLnNhYXMuam9icy52MS5Kb2JTdGF0ZUIGukgDyAEBEjYKCWV4ZWN1dGlvbhgDIAEoCzIjLnNhYXMuam9icy52MS5Kb2JFeGVjdXRpb25SZWZlcmVuY2UiiwEKEVNvdXJjZVN5bmNDaGFuZ2VzEg0KBWZpbGVzGAEgASgNEg0KBWFkZGVkGAIgASgNEhAKCG1vZGlmaWVkGAMgASgNEg8KB2RlbGV0ZWQYBCABKA0SEwoLc3BsaXRfa25vd24YBSABKAgSEAoIc25hcHNob3QYBiABKAgSDgoGY29tbWl0GAcgASgJIq0BChFTb3VyY2VTeW5jRmFpbHVyZRI5CgZyZWFzb24YASABKA4yKS5zYWFzLmFjY291bnRzLnYxLlNvdXJjZVN5bmNGYWlsdXJlUmVhc29uEgwKBGNvZGUYAiABKAkSDwoHbWVzc2FnZRgDIAEoCRIQCghyZXRyeWluZxgEIAEoCBIsCghyZXRyeV9hdBgFIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXAihAQKElNvdXJjZVN5bmNQcm9ncmVzcxIwCgVwaGFzZRgBIAEoDjIhLnNhYXMuYWNjb3VudHMudjEuU291cmNlU3luY1BoYXNlEjQKB3RyaWdnZXIYAiABKA4yIy5zYWFzLmFjY291bnRzLnYxLlNvdXJjZVN5bmNUcmlnZ2VyEi0KCXF1ZXVlZF9hdBgDIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXASLwoLZmV0Y2hpbmdfYXQYBCABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEi8KC2NvbXBpbGVkX2F0GAUgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBIxCg1oYW5kZWRfb2ZmX2F0GAYgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBIvCgtmaW5pc2hlZF9hdBgHIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXASNAoHY2hhbmdlcxgIIAEoCzIjLnNhYXMuYWNjb3VudHMudjEuU291cmNlU3luY0NoYW5nZXMSNAoHZmFpbHVyZRgJIAEoCzIjLnNhYXMuYWNjb3VudHMudjEuU291cmNlU3luY0ZhaWx1cmUSDwoHYXR0ZW1wdBgKIAEoDRIUCgxtYXhfYXR0ZW1wdHMYCyABKA0i0gEKFUdldFNvdXJjZVN5bmNSZXNwb25zZRIYCgZqb2JfaWQYASABKAlCCLpIBXIDsAEBEi0KBXN0YXRlGAIgASgOMhYuc2Fhcy5qb2JzLnYxLkpvYlN0YXRlQga6SAPIAQESOAoKZGVsaXZlcmllcxgDIAMoCzIkLnNhYXMuYWNjb3VudHMudjEuU291cmNlU3luY0RlbGl2ZXJ5EjYKCHByb2dyZXNzGAQgASgLMiQuc2Fhcy5hY2NvdW50cy52MS5Tb3VyY2VTeW5jUHJvZ3Jlc3MiRQoTRGVsZXRlU291cmNlUmVxdWVzdBIYCgZvcmdfaWQYASABKAlCCLpIBXIDsAEBEhQKAmlkGAIgASgJQgi6SAVyA7ABASIWChREZWxldGVTb3VyY2VSZXNwb25zZSJWChNHaXRIdWJBcHBSZXBvc2l0b3J5EgwKBHJlcG8YASABKAkSFgoOZGVmYXVsdF9icmFuY2gYAiABKAkSGQoRYWxyZWFkeV9jb25uZWN0ZWQYAyABKAgiNgoaQmVnaW5HaXRIdWJBcHBTZXR1cFJlcXVlc3QSGAoGb3JnX2lkGAEgASgJQgi6SAVyA7ABASJxChtCZWdpbkdpdEh1YkFwcFNldHVwUmVzcG9uc2USEwoLaW5zdGFsbF91cmwYASABKAkSDQoFc3RhdGUYAiABKAkSLgoKZXhwaXJlc19hdBgDIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXAinAEKHUNvbXBsZXRlR2l0SHViQXBwU2V0dXBSZXF1ZXN0EhgKBm9yZ19pZBgBIAEoCUIIukgFcgOwAQESGQoFc3RhdGUYAiABKAlCCrpIB3IFEAEY/wESLAoPaW5zdGFsbGF0aW9uX2lkGAMgASgJQhO6SBByDhABGCAyCF5bMC05XSskEhgKBGNvZGUYBCABKAlCCrpIB3IFEAEY/wEidgoeQ29tcGxldGVHaXRIdWJBcHBTZXR1cFJlc3BvbnNlEhcKD2luc3RhbGxhdGlvbl9pZBgBIAEoCRI7CgxyZXBvc2l0b3JpZXMYAiADKAsyJS5zYWFzLmFjY291bnRzLnYxLkdpdEh1YkFwcFJlcG9zaXRvcnkiUQofTWlncmF0ZUdpdEh1YlNvdXJjZVRvQXBwUmVxdWVzdBIYCgZvcmdfaWQYASABKAlCCLpIBXIDsAEBEhQKAmlkGAIgASgJQgi6SAVyA7ABASJUCiBNaWdyYXRlR2l0SHViU291cmNlVG9BcHBSZXNwb25zZRIwCgpkYXRhc291cmNlGAEgASgLMhwuc2Fhcy5hY2NvdW50cy52MS5EYXRhc291cmNlIsQBChVEYXRhc291cmNlQWNjb3VudExpbmsSCgoCaWQYASABKAkSDgoGb3JnX2lkGAIgASgJEg8KB3VzZXJfaWQYAyABKAkSEQoJY29ubmVjdG9yGAQgASgJEhsKE3Byb3ZpZGVyX2FjY291bnRfaWQYBSABKAkSHgoWcHJvdmlkZXJfYWNjb3VudF9sb2dpbhgGIAEoCRIuCgpjcmVhdGVkX2F0GAcgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcCK3AQoWRGF0YXNvdXJjZUdyb3VwQmluZGluZxIKCgJpZBgBIAEoCRIOCgZvcmdfaWQYAiABKAkSEQoJY29ubmVjdG9yGAMgASgJEhkKEXByb3ZpZGVyX2dyb3VwX2lkGAQgASgJEg8KB3RlYW1faWQYBSABKAkSEgoKY3JlYXRlZF9ieRgGIAEoCRIuCgpjcmVhdGVkX2F0GAcgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcCKUAgoYRGF0YXNvdXJjZVZlcmlmaWVkRG9tYWluEgoKAmlkGAEgASgJEg4KBm9yZ19pZBgCIAEoCRIOCgZkb21haW4YAyABKAkSOAoGc3RhdHVzGAQgASgOMiguc2Fhcy5hY2NvdW50cy52MS5EYXRhc291cmNlRG9tYWluU3RhdHVzEhcKD3R4dF9yZWNvcmRfbmFtZRgFIAEoCRIYChB0eHRfcmVjb3JkX3ZhbHVlGAYgASgJEi8KC3ZlcmlmaWVkX2F0GAcgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBIuCgpjcmVhdGVkX2F0GAggASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcCIzChdEYXRhc291cmNlRGlyZWN0b3J5VGVhbRIKCgJpZBgBIAEoCRIMCgRuYW1lGAIgASgJIqYBCiFCZWdpbkRhdGFzb3VyY2VBY2NvdW50TGlua1JlcXVlc3QSGAoGb3JnX2lkGAEgASgJQgi6SAVyA7ABARI0Cgljb25uZWN0b3IYAiABKAlCIbpIHnIcEAIYIDIWXlthLXpdW2EtejAtOS1dezEsMzF9JBIeCgxyZWRpcmVjdF91cmkYAyABKAlCCLpIBXIDGIAQEhEKCXNvdXJjZV9pZBgEIAEoCSJ6CiJCZWdpbkRhdGFzb3VyY2VBY2NvdW50TGlua1Jlc3BvbnNlEhUKDWF1dGhvcml6ZV91cmwYASABKAkSDQoFc3RhdGUYAiABKAkSLgoKZXhwaXJlc19hdBgDIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXAidQokQ29tcGxldGVEYXRhc291cmNlQWNjb3VudExpbmtSZXF1ZXN0EhgKBm9yZ19pZBgBIAEoCUIIukgFcgOwAQESGQoFc3RhdGUYAiABKAlCCrpIB3IFEAEYgEASGAoEY29kZRgDIAEoCUIKukgHcgUQARj/ASJeCiVDb21wbGV0ZURhdGFzb3VyY2VBY2NvdW50TGlua1Jlc3BvbnNlEjUKBGxpbmsYASABKAsyJy5zYWFzLmFjY291bnRzLnYxLkRhdGFzb3VyY2VBY2NvdW50TGluayI/CiNMaXN0TXlEYXRhc291cmNlQWNjb3VudExpbmtzUmVxdWVzdBIYCgZvcmdfaWQYASABKAlCCLpIBXIDsAEBIl4KJExpc3RNeURhdGFzb3VyY2VBY2NvdW50TGlua3NSZXNwb25zZRI2CgVsaW5rcxgBIAMoCzInLnNhYXMuYWNjb3VudHMudjEuRGF0YXNvdXJjZUFjY291bnRMaW5rIlQKIkRlbGV0ZURhdGFzb3VyY2VBY2NvdW50TGlua1JlcXVlc3QSGAoGb3JnX2lkGAEgASgJQgi6SAVyA7ABARIUCgJpZBgCIAEoCUIIukgFcgOwAQEiJQojRGVsZXRlRGF0YXNvdXJjZUFjY291bnRMaW5rUmVzcG9uc2UiOQodR2V0RGF0YXNvdXJjZURpcmVjdG9yeVJlcXVlc3QSGAoGb3JnX2lkGAEgASgJQgi6SAVyA7ABASKLAgoeR2V0RGF0YXNvdXJjZURpcmVjdG9yeVJlc3BvbnNlEjYKBWxpbmtzGAEgAygLMicuc2Fhcy5hY2NvdW50cy52MS5EYXRhc291cmNlQWNjb3VudExpbmsSOgoIYmluZGluZ3MYAiADKAsyKC5zYWFzLmFjY291bnRzLnYxLkRhdGFzb3VyY2VHcm91cEJpbmRpbmcSOwoHZG9tYWlucxgDIAMoCzIqLnNhYXMuYWNjb3VudHMudjEuRGF0YXNvdXJjZVZlcmlmaWVkRG9tYWluEjgKBXRlYW1zGAQgAygLMikuc2Fhcy5hY2NvdW50cy52MS5EYXRhc291cmNlRGlyZWN0b3J5VGVhbSKuAQoaQmluZERhdGFzb3VyY2VHcm91cFJlcXVlc3QSGAoGb3JnX2lkGAEgASgJQgi6SAVyA7ABARI0Cgljb25uZWN0b3IYAiABKAlCIbpIHnIcEAIYIDIWXlthLXpdW2EtejAtOS1dezEsMzF9JBIlChFwcm92aWRlcl9ncm91cF9pZBgDIAEoCUIKukgHcgUQARj/ARIZCgd0ZWFtX2lkGAQgASgJQgi6SAVyA7ABASJYChtCaW5kRGF0YXNvdXJjZUdyb3VwUmVzcG9uc2USOQoHYmluZGluZxgBIAEoCzIoLnNhYXMuYWNjb3VudHMudjEuRGF0YXNvdXJjZUdyb3VwQmluZGluZyJOChxVbmJpbmREYXRhc291cmNlR3JvdXBSZXF1ZXN0EhgKBm9yZ19pZBgBIAEoCUIIukgFcgOwAQESFAoCaWQYAiABKAlCCLpIBXIDsAEBIh8KHVVuYmluZERhdGFzb3VyY2VHcm91cFJlc3BvbnNlIlQKHENsYWltRGF0YXNvdXJjZURvbWFpblJlcXVlc3QSGAoGb3JnX2lkGAEgASgJQgi6SAVyA7ABARIaCgZkb21haW4YAiABKAlCCrpIB3IFEAMY/QEiWwodQ2xhaW1EYXRhc291cmNlRG9tYWluUmVzcG9uc2USOgoGZG9tYWluGAEgASgLMiouc2Fhcy5hY2NvdW50cy52MS5EYXRhc291cmNlVmVyaWZpZWREb21haW4iTwodVmVyaWZ5RGF0YXNvdXJjZURvbWFpblJlcXVlc3QSGAoGb3JnX2lkGAEgASgJQgi6SAVyA7ABARIUCgJpZBgCIAEoCUIIukgFcgOwAQEiXAoeVmVyaWZ5RGF0YXNvdXJjZURvbWFpblJlc3BvbnNlEjoKBmRvbWFpbhgBIAEoCzIqLnNhYXMuYWNjb3VudHMudjEuRGF0YXNvdXJjZVZlcmlmaWVkRG9tYWluIk8KHURlbGV0ZURhdGFzb3VyY2VEb21haW5SZXF1ZXN0EhgKBm9yZ19pZBgBIAEoCUIIukgFcgOwAQESFAoCaWQYAiABKAlCCLpIBXIDsAEBIiAKHkRlbGV0ZURhdGFzb3VyY2VEb21haW5SZXNwb25zZSKeAgoQU291cmNlRGVsZWdhdGlvbhIKCgJpZBgBIAEoCRIRCglzb3VyY2VfaWQYAiABKAkSFAoMcHJpbmNpcGFsX2lkGAMgASgJEg4KBm1vZHVsZRgEIAEoCRIPCgdiaW5kaW5nGAUgASgJEi4KCmNyZWF0ZWRfYXQYBiABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEi4KCnJldm9rZWRfYXQYByABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEkAKCnJldm9jYXRpb24YCCABKA4yLC5zYWFzLmFjY291bnRzLnYxLlNvdXJjZURlbGVnYXRpb25SZXZvY2F0aW9uEhIKCnJldm9rZWRfYnkYCSABKAkigQEKHExpc3RTb3VyY2VEZWxlZ2F0aW9uc1JlcXVlc3QSGAoGb3JnX2lkGAEgASgJQgi6SAVyA7ABARIgCglzb3VyY2VfaWQYAiABKAlCCLpIBXIDsAEBSACIAQESFwoPaW5jbHVkZV9yZXZva2VkGAMgASgIQgwKCl9zb3VyY2VfaWQiWAodTGlzdFNvdXJjZURlbGVnYXRpb25zUmVzcG9uc2USNwoLZGVsZWdhdGlvbnMYASADKAsyIi5zYWFzLmFjY291bnRzLnYxLlNvdXJjZURlbGVnYXRpb24iTwodUmV2b2tlU291cmNlRGVsZWdhdGlvblJlcXVlc3QSGAoGb3JnX2lkGAEgASgJQgi6SAVyA7ABARIUCgJpZBgCIAEoCUIIukgFcgOwAQEiWAoeUmV2b2tlU291cmNlRGVsZWdhdGlvblJlc3BvbnNlEjYKCmRlbGVnYXRpb24YASABKAsyIi5zYWFzLmFjY291bnRzLnYxLlNvdXJjZURlbGVnYXRpb24iMgoPVHJpZ2dlcklkZW50aXR5Eg4KBnNvdXJjZRgBIAEoCRIPCgdiaW5kaW5nGAIgASgJIoQBChdEZWNsYXJlZFRyaWdnZXJTY2hlZHVsZRIMCgRjcm9uGAEgASgJEhAKCHRpbWV6b25lGAIgASgJEhIKCm1pc3NlZF9ydW4YAyABKAkSDwoHb3ZlcmxhcBgEIAEoCRIQCghkZWFkbGluZRgFIAEoAxISCgppbnB1dF9qc29uGAYgASgJIucCChpUcmlnZ2VyRGVsZWdhdGlvblJlZmVyZW5jZRIRCglyZWZlcmVuY2UYASABKAkSFQoNZGVsZWdhdGlvbl9pZBgCIAEoCRIOCgZtb2R1bGUYAyABKAkSDwoHYmluZGluZxgEIAEoCRIQCghhdWRpZW5jZRgFIAEoCRIOCgZ0ZW5hbnQYBiABKAkSGgoSb3duZXJfcHJpbmNpcGFsX2lkGAcgASgJEg4KBnNjb3BlcxgIIAMoCRIOCgZhY3RpdmUYCSABKAgSLgoKY3JlYXRlZF9hdBgKIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXASLgoKcmV2b2tlZF9hdBgLIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXASQAoKcmV2b2NhdGlvbhgMIAEoDjIsLnNhYXMuYWNjb3VudHMudjEuU291cmNlRGVsZWdhdGlvblJldm9jYXRpb24ivgEKE1RyaWdnZXJDaGlsZENvbnRleHQSFQoNZGVsZWdhdGlvbl9pZBgBIAEoCRIOCgZtb2R1bGUYAiABKAkSDwoHYmluZGluZxgDIAEoCRIQCghhdWRpZW5jZRgEIAEoCRIOCgZzY29wZXMYBSADKAkSDgoGbG9va3VwGAYgASgIEi0KCW1pbnRlZF9hdBgHIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXASDgoGcmVjb3JkGAggASgJIs0CChhTb3VyY2VUcmlnZ2VyRGVjbGFyYXRpb24SDwoHcmVsZWFzZRgBIAEoCRIRCglvcGVyYXRpb24YAiABKAkSOwoIc2NoZWR1bGUYAyABKAsyKS5zYWFzLmFjY291bnRzLnYxLkRlY2xhcmVkVHJpZ2dlclNjaGVkdWxlEjMKCGlkZW50aXR5GAQgASgLMiEuc2Fhcy5hY2NvdW50cy52MS5UcmlnZ2VySWRlbnRpdHkSDgoGZGlnZXN0GAUgASgJEjYKEmxhc3Rfb2NjdXJyZW5jZV9hdBgGIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXASNgoSbmV4dF9vY2N1cnJlbmNlX2F0GAcgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBIbChN1bmRlY2xhcmFibGVfcmVhc29uGAggASgJIlYKHURlbGVnYXRlU291cmNlVHJpZ2dlcnNSZXF1ZXN0EhgKBm9yZ19pZBgBIAEoCUIIukgFcgOwAQESGwoJc291cmNlX2lkGAIgASgJQgi6SAVyA7ABASJjCh5EZWxlZ2F0ZVNvdXJjZVRyaWdnZXJzUmVzcG9uc2USQQoLZGVsZWdhdGlvbnMYASADKAsyLC5zYWFzLmFjY291bnRzLnYxLlRyaWdnZXJEZWxlZ2F0aW9uUmVmZXJlbmNlIlsKIkdldFNvdXJjZVRyaWdnZXJEZWNsYXJhdGlvblJlcXVlc3QSGAoGb3JnX2lkGAEgASgJQgi6SAVyA7ABARIbCglzb3VyY2VfaWQYAiABKAlCCLpIBXIDsAEBIuIBCiNHZXRTb3VyY2VUcmlnZ2VyRGVjbGFyYXRpb25SZXNwb25zZRI/CgtkZWNsYXJhdGlvbhgBIAEoCzIqLnNhYXMuYWNjb3VudHMudjEuU291cmNlVHJpZ2dlckRlY2xhcmF0aW9uEkEKC2RlbGVnYXRpb25zGAIgAygLMiwuc2Fhcy5hY2NvdW50cy52MS5UcmlnZ2VyRGVsZWdhdGlvblJlZmVyZW5jZRI3CghjaGlsZHJlbhgDIAMoCzIlLnNhYXMuYWNjb3VudHMudjEuVHJpZ2dlckNoaWxkQ29udGV4dCJuChZSZWNvbmNpbGVTb3VyY2VSZXF1ZXN0EhgKBm9yZ19pZBgBIAEoCUIIukgFcgOwAQESGwoJc291cmNlX2lkGAIgASgJQgi6SAVyA7ABARIdCgllZmZlY3RfaWQYAyABKAlCCrpIB3IFEAEYgAEiTAoXUmVjb25jaWxlU291cmNlUmVzcG9uc2USEQoJZWZmZWN0X2lkGAEgASgJEg4KBnN0YXR1cxgCIAEoCRIOCgZqb2JfaWQYAyABKAkiPQocTG9va3VwUmVjb25jaWxlU291cmNlUmVxdWVzdBIdCgllZmZlY3RfaWQYASABKAlCCrpIB3IFEAEYgAEi8QIKD1NvdXJjZU9wZXJhdGlvbhIMCgRuYW1lGAEgASgJEhMKC2Rlc2NyaXB0aW9uGAIgASgJEg4KBm1ldGhvZBgDIAEoCRIMCgRwYXRoGAQgASgJEg0KBXF1ZXJ5GAUgAygJEi0KDGlucHV0X3NjaGVtYRgGIAEoCzIXLmdvb2dsZS5wcm90b2J1Zi5TdHJ1Y3QSLgoNb3V0cHV0X3NjaGVtYRgHIAEoCzIXLmdvb2dsZS5wcm90b2J1Zi5TdHJ1Y3QSOAoGZWZmZWN0GAggASgOMiguc2Fhcy5hY2NvdW50cy52MS5Tb3VyY2VPcGVyYXRpb24uRWZmZWN0EhgKEG1heF9vdXRwdXRfYnl0ZXMYCSABKA0SDgoGZGlnZXN0GAogASgJIksKBkVmZmVjdBIWChJFRkZFQ1RfVU5TUEVDSUZJRUQQABIUChBFRkZFQ1RfUkVBRF9PTkxZEAESEwoPRUZGRUNUX01VVEFUSU9OEAIijgEKHkRlY2xhcmVTb3VyY2VPcGVyYXRpb25zUmVxdWVzdBIYCgZvcmdfaWQYASABKAlCCLpIBXIDsAEBEhsKCXNvdXJjZV9pZBgCIAEoCUIIukgFcgOwAQESNQoKb3BlcmF0aW9ucxgDIAMoCzIhLnNhYXMuYWNjb3VudHMudjEuU291cmNlT3BlcmF0aW9uIlgKH0RlY2xhcmVTb3VyY2VPcGVyYXRpb25zUmVzcG9uc2USNQoKb3BlcmF0aW9ucxgBIAMoCzIhLnNhYXMuYWNjb3VudHMudjEuU291cmNlT3BlcmF0aW9uIlQKG0xpc3RTb3VyY2VPcGVyYXRpb25zUmVxdWVzdBIYCgZvcmdfaWQYASABKAlCCLpIBXIDsAEBEhsKCXNvdXJjZV9pZBgCIAEoCUIIukgFcgOwAQEiVQocTGlzdFNvdXJjZU9wZXJhdGlvbnNSZXNwb25zZRI1CgpvcGVyYXRpb25zGAEgAygLMiEuc2Fhcy5hY2NvdW50cy52MS5Tb3VyY2VPcGVyYXRpb24iwwEKHEludm9rZVNvdXJjZU9wZXJhdGlvblJlcXVlc3QSGAoGb3JnX2lkGAEgASgJQgi6SAVyA7ABARIbCglzb3VyY2VfaWQYAiABKAlCCLpIBXIDsAEBEjAKCW9wZXJhdGlvbhgDIAEoCUIdukgachgyFl5bYS16XVthLXowLTlfXXswLDYzfSQSHQoKaW5wdXRfanNvbhgEIAEoCUIJukgGcgQogIAEEhsKCWVmZmVjdF9pZBgFIAEoCUIIukgFcgMogAEifwoWU291cmNlT3BlcmF0aW9uUmVjZWlwdBIRCgllZmZlY3RfaWQYASABKAkSFAoMY29tbWl0dGVkX2F0GAIgASgJEg4KBnN0YXR1cxgDIAEoCRIXCg9wcm92aWRlcl9zdGF0dXMYBCABKA0SEwoLb3V0cHV0X2pzb24YBSABKAkibwodSW52b2tlU291cmNlT3BlcmF0aW9uUmVzcG9uc2USEwoLb3V0cHV0X2pzb24YASABKAkSOQoHcmVjZWlwdBgCIAEoCzIoLnNhYXMuYWNjb3VudHMudjEuU291cmNlT3BlcmF0aW9uUmVjZWlwdCJDCiJMb29rdXBJbnZva2VTb3VyY2VPcGVyYXRpb25SZXF1ZXN0Eh0KCWVmZmVjdF9pZBgBIAEoCUIKukgHcgUQASiAASJ5CiNQcnVuZVNvdXJjZU9wZXJhdGlvblJlY2VpcHRzUmVxdWVzdBIYCgZvcmdfaWQYASABKAlCCLpIBXIDsAEBEhsKCXNvdXJjZV9pZBgCIAEoCUIIukgFcgOwAQESGwoJZWZmZWN0X2lkGAMgASgJQgi6SAVyAyiAASJbCiRQcnVuZVNvdXJjZU9wZXJhdGlvblJlY2VpcHRzUmVzcG9uc2USEQoJZWZmZWN0X2lkGAEgASgJEhAKCHJlY2VpcHRzGAIgASgDEg4KBnN0YXR1cxgDIAEoCSJKCilMb29rdXBQcnVuZVNvdXJjZU9wZXJhdGlvblJlY2VpcHRzUmVxdWVzdBIdCgllZmZlY3RfaWQYASABKAlCCrpIB3IFEAEogAEqtwEKEkRhdGFzb3VyY2VQcm92aWRlchIjCh9EQVRBU09VUkNFX1BST1ZJREVSX1VOU1BFQ0lGSUVEEAASHgoaREFUQVNPVVJDRV9QUk9WSURFUl9HSVRIVUIQARIbChdEQVRBU09VUkNFX1BST1ZJREVSX0FQSRACEh8KG0RBVEFTT1VSQ0VfUFJPVklERVJfQ1JBV0xFUhADEh4KGkRBVEFTT1VSQ0VfUFJPVklERVJfVVBMT0FEEAQqkQEKEERhdGFzb3VyY2VTdGF0dXMSIQodREFUQVNPVVJDRV9TVEFUVVNfVU5TUEVDSUZJRUQQABIcChhEQVRBU09VUkNFX1NUQVRVU19BQ1RJVkUQARIcChhEQVRBU09VUkNFX1NUQVRVU19QQVVTRUQQAhIeChpEQVRBU09VUkNFX1NUQVRVU19ERUdSQURFRBADKrwBChZEYXRhc291cmNlTGl2ZURlbGl2ZXJ5EigKJERBVEFTT1VSQ0VfTElWRV9ERUxJVkVSWV9VTlNQRUNJRklFRBAAEiEKHURBVEFTT1VSQ0VfTElWRV9ERUxJVkVSWV9OT05FEAESKwonREFUQVNPVVJDRV9MSVZFX0RFTElWRVJZX1NPVVJDRV9XRUJIT09LEAISKAokREFUQVNPVVJDRV9MSVZFX0RFTElWRVJZX0FQUF9XRUJIT09LEAMq1gEKEUFwaUNyZWRlbnRpYWxLaW5kEiMKH0FQSV9DUkVERU5USUFMX0tJTkRfVU5TUEVDSUZJRUQQABIeChpBUElfQ1JFREVOVElBTF9LSU5EX0JFQVJFUhABEh0KGUFQSV9DUkVERU5USUFMX0tJTkRfQkFTSUMQAhIeChpBUElfQ1JFREVOVElBTF9LSU5EX0hFQURFUhADEh0KGUFQSV9DUkVERU5USUFMX0tJTkRfUVVFUlkQBBIeChpBUElfQ1JFREVOVElBTF9LSU5EX09BVVRIMhAFKqYCChxEYXRhc291cmNlQ29ubmVjdG9ySW50ZXJmYWNlEi4KKkRBVEFTT1VSQ0VfQ09OTkVDVE9SX0lOVEVSRkFDRV9VTlNQRUNJRklFRBAAEigKJERBVEFTT1VSQ0VfQ09OTkVDVE9SX0lOVEVSRkFDRV9GSUxFUxABEigKJERBVEFTT1VSQ0VfQ09OTkVDVE9SX0lOVEVSRkFDRV9QQUdFUxACEioKJkRBVEFTT1VSQ0VfQ09OTkVDVE9SX0lOVEVSRkFDRV9SRUNPUkRTEAMSKwonREFUQVNPVVJDRV9DT05ORUNUT1JfSU5URVJGQUNFX01FU1NBR0VTEAQSKQolREFUQVNPVVJDRV9DT05ORUNUT1JfSU5URVJGQUNFX0VWRU5UUxAFKuwBChhEYXRhc291cmNlQ3JlZGVudGlhbE1vZGUSKgomREFUQVNPVVJDRV9DUkVERU5USUFMX01PREVfVU5TUEVDSUZJRUQQABIjCh9EQVRBU09VUkNFX0NSRURFTlRJQUxfTU9ERV9OT05FEAESJgoiREFUQVNPVVJDRV9DUkVERU5USUFMX01PREVfT1JHX0FQUBACEikKJURBVEFTT1VSQ0VfQ1JFREVOVElBTF9NT0RFX1VTRVJfT0FVVEgQAxIsCihEQVRBU09VUkNFX0NSRURFTlRJQUxfTU9ERV9TVEFUSUNfU0VDUkVUEAQqlwEKFkRhdGFzb3VyY2VSZWFkZXJzTW9kZWwSKAokREFUQVNPVVJDRV9SRUFERVJTX01PREVMX1VOU1BFQ0lGSUVEEAASKgomREFUQVNPVVJDRV9SRUFERVJTX01PREVMX1NPVVJDRV9TQ09QRUQQARInCiNEQVRBU09VUkNFX1JFQURFUlNfTU9ERUxfVFJBTlNMQVRFRBACKu4BCg9Tb3VyY2VTeW5jUGhhc2USIQodU09VUkNFX1NZTkNfUEhBU0VfVU5TUEVDSUZJRUQQABIcChhTT1VSQ0VfU1lOQ19QSEFTRV9RVUVVRUQQARIeChpTT1VSQ0VfU1lOQ19QSEFTRV9GRVRDSElORxACEh4KGlNPVVJDRV9TWU5DX1BIQVNFX0NPTVBJTEVEEAMSIAocU09VUkNFX1NZTkNfUEhBU0VfSEFOREVEX09GRhAEEhoKFlNPVVJDRV9TWU5DX1BIQVNFX0RPTkUQBRIcChhTT1VSQ0VfU1lOQ19QSEFTRV9GQUlMRUQQBiqmAwoXU291cmNlU3luY0ZhaWx1cmVSZWFzb24SKgomU09VUkNFX1NZTkNfRkFJTFVSRV9SRUFTT05fVU5TUEVDSUZJRUQQABIrCidTT1VSQ0VfU1lOQ19GQUlMVVJFX1JFQVNPTl9SQVRFX0xJTUlURUQQARIpCiVTT1VSQ0VfU1lOQ19GQUlMVVJFX1JFQVNPTl9DUkVERU5USUFMEAISLAooU09VUkNFX1NZTkNfRkFJTFVSRV9SRUFTT05fQUNDRVNTX0RFTklFRBADEigKJFNPVVJDRV9TWU5DX0ZBSUxVUkVfUkVBU09OX05PVF9GT1VORBAEEigKJFNPVVJDRV9TWU5DX0ZBSUxVUkVfUkVBU09OX1RPT19MQVJHRRAFEi8KK1NPVVJDRV9TWU5DX0ZBSUxVUkVfUkVBU09OX0hPU1RfVU5BVkFJTEFCTEUQBhIkCiBTT1VSQ0VfU1lOQ19GQUlMVVJFX1JFQVNPTl9PVEhFUhAHEi4KKlNPVVJDRV9TWU5DX0ZBSUxVUkVfUkVBU09OX0RFTElWRVJZX0ZBSUxFRBAIKsIBChFTb3VyY2VTeW5jVHJpZ2dlchIjCh9TT1VSQ0VfU1lOQ19UUklHR0VSX1VOU1BFQ0lGSUVEEAASHgoaU09VUkNFX1NZTkNfVFJJR0dFUl9NQU5VQUwQARIhCh1TT1VSQ0VfU1lOQ19UUklHR0VSX1NDSEVEVUxFRBACEh8KG1NPVVJDRV9TWU5DX1RSSUdHRVJfV0VCSE9PSxADEiQKIFNPVVJDRV9TWU5DX1RSSUdHRVJfUkVDT05GSUdVUkVEEAQqjwEKFkRhdGFzb3VyY2VEb21haW5TdGF0dXMSKAokREFUQVNPVVJDRV9ET01BSU5fU1RBVFVTX1VOU1BFQ0lGSUVEEAASJAogREFUQVNPVVJDRV9ET01BSU5fU1RBVFVTX1BFTkRJTkcQARIlCiFEQVRBU09VUkNFX0RPTUFJTl9TVEFUVVNfVkVSSUZJRUQQAiqVAwoaU291cmNlRGVsZWdhdGlvblJldm9jYXRpb24SLAooU09VUkNFX0RFTEVHQVRJT05fUkVWT0NBVElPTl9VTlNQRUNJRklFRBAAEigKJFNPVVJDRV9ERUxFR0FUSU9OX1JFVk9DQVRJT05fUkVWT0tFRBABEikKJVNPVVJDRV9ERUxFR0FUSU9OX1JFVk9DQVRJT05fUkVQTEFDRUQQAhIvCitTT1VSQ0VfREVMRUdBVElPTl9SRVZPQ0FUSU9OX1NPVVJDRV9ERUxFVEVEEAMSLworU09VUkNFX0RFTEVHQVRJT05fUkVWT0NBVElPTl9NRU1CRVJfUkVNT1ZFRBAEEjAKLFNPVVJDRV9ERUxFR0FUSU9OX1JFVk9DQVRJT05fUEVSTUlTU0lPTl9MT1NUEAUSLgoqU09VUkNFX0RFTEVHQVRJT05fUkVWT0NBVElPTl9VU0VSX0lOQUNUSVZFEAYSMAosU09VUkNFX0RFTEVHQVRJT05fUkVWT0NBVElPTl9CSU5ESU5HX0NIQU5HRUQQBzKxOQoRRGF0YXNvdXJjZVNlcnZpY2USuQMKHFBydW5lU291cmNlT3BlcmF0aW9uUmVjZWlwdHMSNS5zYWFzLmFjY291bnRzLnYxLlBydW5lU291cmNlT3BlcmF0aW9uUmVjZWlwdHNSZXF1ZXN0GjYuc2Fhcy5hY2NvdW50cy52MS5QcnVuZVNvdXJjZU9wZXJhdGlvblJlY2VpcHRzUmVzcG9uc2UiqQLC8xhsCAIQAxoRZGF0YXNvdXJjZTppbnZva2UiEWRhdGFzb3VyY2U6aW52b2tlKg8KCXNvdXJjZV9pZBAEGAMwATojCh9zYWFzLmRhdGFzb3VyY2UucmVjZWlwdHMucHJ1bmVkEAJAA0gEUANYA2AB2vMYtAEKAgg8EgMI2AQYBSICCAIqC1VOQVZBSUxBQkxFKhJSRVNPVVJDRV9FWEhBVVNURUQqB0FCT1JURUQyD3NhYXMtZGF0YXNvdXJjZUpGL3NhYXMuYWNjb3VudHMudjEuRGF0YXNvdXJjZVNlcnZpY2UvTG9va3VwUHJ1bmVTb3VyY2VPcGVyYXRpb25SZWNlaXB0c1CAIFiAIGABchgKBnNvdXJjZRIGaW52b2tlEgRyZWFkGAES5gEKIkxvb2t1cFBydW5lU291cmNlT3BlcmF0aW9uUmVjZWlwdHMSOy5zYWFzLmFjY291bnRzLnYxLkxvb2t1cFBydW5lU291cmNlT3BlcmF0aW9uUmVjZWlwdHNSZXF1ZXN0GjYuc2Fhcy5hY2NvdW50cy52MS5QcnVuZVNvdXJjZU9wZXJhdGlvblJlY2VpcHRzUmVzcG9uc2UiS8LzGEcIAhADGg9kYXRhc291cmNlOnJlYWQiD2RhdGFzb3VyY2U6cmVhZCoPCgllZmZlY3RfaWQQBBgDMAE6AhABQAFIA1ADWANgARL8AwoVSW52b2tlU291cmNlT3BlcmF0aW9uEi4uc2Fhcy5hY2NvdW50cy52MS5JbnZva2VTb3VyY2VPcGVyYXRpb25SZXF1ZXN0Gi8uc2Fhcy5hY2NvdW50cy52MS5JbnZva2VTb3VyY2VPcGVyYXRpb25SZXNwb25zZSKBA8LzGG4IAhADGhFkYXRhc291cmNlOmludm9rZSIRZGF0YXNvdXJjZTppbnZva2UqDwoJc291cmNlX2lkEAQYAzABOiUKIXNhYXMuZGF0YXNvdXJjZS5vcGVyYXRpb24uaW52b2tlZBAEQANIBFAEWARgAdrzGK8BCgIIPBIDCNgEGAUiAggCKgtVTkFWQUlMQUJMRSoSUkVTT1VSQ0VfRVhIQVVTVEVEKgdBQk9SVEVEMg9zYWFzLWRhdGFzb3VyY2VKPy9zYWFzLmFjY291bnRzLnYxLkRhdGFzb3VyY2VTZXJ2aWNlL0xvb2t1cEludm9rZVNvdXJjZU9wZXJhdGlvblCAgARYgIBAYAFyGAoGc291cmNlEgZpbnZva2USBHJlYWQYAYLT5JMCVToBKiJQL3YxL29yZ2FuaXphdGlvbnMve29yZ19pZH0vZGF0YXNvdXJjZXMve3NvdXJjZV9pZH0vb3BlcmF0aW9ucy97b3BlcmF0aW9ufTppbnZva2UShwIKG0xvb2t1cEludm9rZVNvdXJjZU9wZXJhdGlvbhI0LnNhYXMuYWNjb3VudHMudjEuTG9va3VwSW52b2tlU291cmNlT3BlcmF0aW9uUmVxdWVzdBovLnNhYXMuYWNjb3VudHMudjEuSW52b2tlU291cmNlT3BlcmF0aW9uUmVzcG9uc2UigAHC8xhHCAIQAxoPZGF0YXNvdXJjZTpyZWFkIg9kYXRhc291cmNlOnJlYWQqDwoJZWZmZWN0X2lkEAQYAzABOgIQAUABSANQA1gEYAGC0+STAi8SLS92MS9kYXRhc291cmNlLW9wZXJhdGlvbi1yZWNlaXB0cy97ZWZmZWN0X2lkfRKFAgoUTGlzdFNvdXJjZU9wZXJhdGlvbnMSLS5zYWFzLmFjY291bnRzLnYxLkxpc3RTb3VyY2VPcGVyYXRpb25zUmVxdWVzdBouLnNhYXMuYWNjb3VudHMudjEuTGlzdFNvdXJjZU9wZXJhdGlvbnNSZXNwb25zZSKNAcLzGEQIAhADGg9kYXRhc291cmNlOnJlYWQiD2RhdGFzb3VyY2U6cmVhZCoMCgZvcmdfaWQQAhgBMAE6AhABQAFIA1ADWANgAYLT5JMCPxI9L3YxL29yZ2FuaXphdGlvbnMve29yZ19pZH0vZGF0YXNvdXJjZXMve3NvdXJjZV9pZH0vb3BlcmF0aW9ucxKTAgoXRGVjbGFyZVNvdXJjZU9wZXJhdGlvbnMSMC5zYWFzLmFjY291bnRzLnYxLkRlY2xhcmVTb3VyY2VPcGVyYXRpb25zUmVxdWVzdBoxLnNhYXMuYWNjb3VudHMudjEuRGVjbGFyZVNvdXJjZU9wZXJhdGlvbnNSZXNwb25zZSKSAcLzGEYIAhAEKgwKBm9yZ19pZBACGAEwATomCiJzYWFzLmRhdGFzb3VyY2Uub3BlcmF0aW9uLmRlY2xhcmVkEAJAAUgEUANYA2ABgtPkkwJCOgEqIj0vdjEvb3JnYW5pemF0aW9ucy97b3JnX2lkfS9kYXRhc291cmNlcy97c291cmNlX2lkfS9vcGVyYXRpb25zEqwBCg9BZGRHaXRIdWJTb3VyY2USKC5zYWFzLmFjY291bnRzLnYxLkFkZEdpdEh1YlNvdXJjZVJlcXVlc3QaKS5zYWFzLmFjY291bnRzLnYxLkFkZEdpdEh1YlNvdXJjZVJlc3BvbnNlIkTC8xhACAIQBCoMCgZvcmdfaWQQAhgBMAE6IAocc2Fhcy5kYXRhc291cmNlLnNvdXJjZS5hZGRlZBACQAFIBFAEWANgARKaAQoJQWRkU291cmNlEiIuc2Fhcy5hY2NvdW50cy52MS5BZGRTb3VyY2VSZXF1ZXN0GiMuc2Fhcy5hY2NvdW50cy52MS5BZGRTb3VyY2VSZXNwb25zZSJEwvMYQAgCEAQqDAoGb3JnX2lkEAIYATABOiAKHHNhYXMuZGF0YXNvdXJjZS5zb3VyY2UuYWRkZWQQAkABSARQBFgDYAESpQEKDFVwZGF0ZVNvdXJjZRIlLnNhYXMuYWNjb3VudHMudjEuVXBkYXRlU291cmNlUmVxdWVzdBomLnNhYXMuYWNjb3VudHMudjEuVXBkYXRlU291cmNlUmVzcG9uc2UiRsLzGEIIAhAEKgwKBm9yZ19pZBACGAEwAToiCh5zYWFzLmRhdGFzb3VyY2Uuc291cmNlLnVwZGF0ZWQQAkABSARQAlgDYAESjwEKFEdldERhdGFzb3VyY2VDYXRhbG9nEi0uc2Fhcy5hY2NvdW50cy52MS5HZXREYXRhc291cmNlQ2F0YWxvZ1JlcXVlc3QaLi5zYWFzLmFjY291bnRzLnYxLkdldERhdGFzb3VyY2VDYXRhbG9nUmVzcG9uc2UiGMLzGBQIAhABMAE6AhABQAFIA1ACWAJgARKCAQoLTGlzdFNvdXJjZXMSJC5zYWFzLmFjY291bnRzLnYxLkxpc3RTb3VyY2VzUmVxdWVzdBolLnNhYXMuYWNjb3VudHMudjEuTGlzdFNvdXJjZXNSZXNwb25zZSImwvMYIggCEAMqDAoGb3JnX2lkEAIYATABOgIQAUABSANQAlgDYAESfAoJR2V0U291cmNlEiIuc2Fhcy5hY2NvdW50cy52MS5HZXRTb3VyY2VSZXF1ZXN0GiMuc2Fhcy5hY2NvdW50cy52MS5HZXRTb3VyY2VSZXNwb25zZSImwvMYIggCEAMqDAoGb3JnX2lkEAIYATABOgIQAUABSANQAlgDYAESngEKClN5bmNTb3VyY2USIy5zYWFzLmFjY291bnRzLnYxLlN5bmNTb3VyY2VSZXF1ZXN0GiQuc2Fhcy5hY2NvdW50cy52MS5TeW5jU291cmNlUmVzcG9uc2UiRcLzGEEIAhAEKgwKBm9yZ19pZBACGAEwATohCh1zYWFzLmRhdGFzb3VyY2Uuc291cmNlLnN5bmNlZBACQAFIBFAEWAJgARKIAQoNR2V0U291cmNlU3luYxImLnNhYXMuYWNjb3VudHMudjEuR2V0U291cmNlU3luY1JlcXVlc3QaJy5zYWFzLmFjY291bnRzLnYxLkdldFNvdXJjZVN5bmNSZXNwb25zZSImwvMYIggCEAQqDAoGb3JnX2lkEAIYATABOgIQAUABSANQAlgDYAESpQEKDERlbGV0ZVNvdXJjZRIlLnNhYXMuYWNjb3VudHMudjEuRGVsZXRlU291cmNlUmVxdWVzdBomLnNhYXMuYWNjb3VudHMudjEuRGVsZXRlU291cmNlUmVzcG9uc2UiRsLzGEIIAhAEKgwKBm9yZ19pZBACGAEwAToiCh5zYWFzLmRhdGFzb3VyY2Uuc291cmNlLnJlbW92ZWQQAkABSARQAlgCYAESxAEKE0JlZ2luR2l0SHViQXBwU2V0dXASLC5zYWFzLmFjY291bnRzLnYxLkJlZ2luR2l0SHViQXBwU2V0dXBSZXF1ZXN0Gi0uc2Fhcy5hY2NvdW50cy52MS5CZWdpbkdpdEh1YkFwcFNldHVwUmVzcG9uc2UiUMLzGEwIAhAEKgwKBm9yZ19pZBACGAEwATosCihzYWFzLmRhdGFzb3VyY2UuZ2l0aHViX2FwcC5zZXR1cF9zdGFydGVkEAJAAUgEUAJYBGABEs8BChZDb21wbGV0ZUdpdEh1YkFwcFNldHVwEi8uc2Fhcy5hY2NvdW50cy52MS5Db21wbGV0ZUdpdEh1YkFwcFNldHVwUmVxdWVzdBowLnNhYXMuYWNjb3VudHMudjEuQ29tcGxldGVHaXRIdWJBcHBTZXR1cFJlc3BvbnNlIlLC8xhOCAIQBCoMCgZvcmdfaWQQAhgBMAE6Lgoqc2Fhcy5kYXRhc291cmNlLmdpdGh1Yl9hcHAuc2V0dXBfY29tcGxldGVkEAJAAUgEUARYA2ABEs0BChhNaWdyYXRlR2l0SHViU291cmNlVG9BcHASMS5zYWFzLmFjY291bnRzLnYxLk1pZ3JhdGVHaXRIdWJTb3VyY2VUb0FwcFJlcXVlc3QaMi5zYWFzLmFjY291bnRzLnYxLk1pZ3JhdGVHaXRIdWJTb3VyY2VUb0FwcFJlc3BvbnNlIkrC8xhGCAIQBCoMCgZvcmdfaWQQAhgBMAE6Jgoic2Fhcy5kYXRhc291cmNlLmNyZWRlbnRpYWwudXBkYXRlZBACQAFIBFACWANgARLVAQoaQmVnaW5EYXRhc291cmNlQWNjb3VudExpbmsSMy5zYWFzLmFjY291bnRzLnYxLkJlZ2luRGF0YXNvdXJjZUFjY291bnRMaW5rUmVxdWVzdBo0LnNhYXMuYWNjb3VudHMudjEuQmVnaW5EYXRhc291cmNlQWNjb3VudExpbmtSZXNwb25zZSJMwvMYSAgCEAMqDAoGb3JnX2lkEAIYATABOigKJHNhYXMuZGF0YXNvdXJjZS5hY2NvdW50X2xpbmtfc3RhcnRlZBACQAFIBFACWARgARLYAQodQ29tcGxldGVEYXRhc291cmNlQWNjb3VudExpbmsSNi5zYWFzLmFjY291bnRzLnYxLkNvbXBsZXRlRGF0YXNvdXJjZUFjY291bnRMaW5rUmVxdWVzdBo3LnNhYXMuYWNjb3VudHMudjEuQ29tcGxldGVEYXRhc291cmNlQWNjb3VudExpbmtSZXNwb25zZSJGwvMYQggCEAMqDAoGb3JnX2lkEAIYATABOiIKHnNhYXMuZGF0YXNvdXJjZS5hY2NvdW50X2xpbmtlZBACQAFIBFAEWANgARK1AQocTGlzdE15RGF0YXNvdXJjZUFjY291bnRMaW5rcxI1LnNhYXMuYWNjb3VudHMudjEuTGlzdE15RGF0YXNvdXJjZUFjY291bnRMaW5rc1JlcXVlc3QaNi5zYWFzLmFjY291bnRzLnYxLkxpc3RNeURhdGFzb3VyY2VBY2NvdW50TGlua3NSZXNwb25zZSImwvMYIggCEAMqDAoGb3JnX2lkEAIYATABOgIQAUABSANQAlgDYAES1AEKG0RlbGV0ZURhdGFzb3VyY2VBY2NvdW50TGluaxI0LnNhYXMuYWNjb3VudHMudjEuRGVsZXRlRGF0YXNvdXJjZUFjY291bnRMaW5rUmVxdWVzdBo1LnNhYXMuYWNjb3VudHMudjEuRGVsZXRlRGF0YXNvdXJjZUFjY291bnRMaW5rUmVzcG9uc2UiSMLzGEQIAhADKgwKBm9yZ19pZBACGAEwATokCiBzYWFzLmRhdGFzb3VyY2UuYWNjb3VudF91bmxpbmtlZBACQAFIBFACWANgARKjAQoWR2V0RGF0YXNvdXJjZURpcmVjdG9yeRIvLnNhYXMuYWNjb3VudHMudjEuR2V0RGF0YXNvdXJjZURpcmVjdG9yeVJlcXVlc3QaMC5zYWFzLmFjY291bnRzLnYxLkdldERhdGFzb3VyY2VEaXJlY3RvcnlSZXNwb25zZSImwvMYIggCEAQqDAoGb3JnX2lkEAIYATABOgIQAUABSANQAlgDYAEStwEKE0JpbmREYXRhc291cmNlR3JvdXASLC5zYWFzLmFjY291bnRzLnYxLkJpbmREYXRhc291cmNlR3JvdXBSZXF1ZXN0Gi0uc2Fhcy5hY2NvdW50cy52MS5CaW5kRGF0YXNvdXJjZUdyb3VwUmVzcG9uc2UiQ8LzGD8IAhAEKgwKBm9yZ19pZBACGAEwATofChtzYWFzLmRhdGFzb3VyY2UuZ3JvdXBfYm91bmQQAkABSARQAlgDYAESvwEKFVVuYmluZERhdGFzb3VyY2VHcm91cBIuLnNhYXMuYWNjb3VudHMudjEuVW5iaW5kRGF0YXNvdXJjZUdyb3VwUmVxdWVzdBovLnNhYXMuYWNjb3VudHMudjEuVW5iaW5kRGF0YXNvdXJjZUdyb3VwUmVzcG9uc2UiRcLzGEEIAhAEKgwKBm9yZ19pZBACGAEwATohCh1zYWFzLmRhdGFzb3VyY2UuZ3JvdXBfdW5ib3VuZBACQAFIBFACWANgARLAAQoVQ2xhaW1EYXRhc291cmNlRG9tYWluEi4uc2Fhcy5hY2NvdW50cy52MS5DbGFpbURhdGFzb3VyY2VEb21haW5SZXF1ZXN0Gi8uc2Fhcy5hY2NvdW50cy52MS5DbGFpbURhdGFzb3VyY2VEb21haW5SZXNwb25zZSJGwvMYQggCEAQqDAoGb3JnX2lkEAIYATABOiIKHnNhYXMuZGF0YXNvdXJjZS5kb21haW5fY2xhaW1lZBACQAFIBFACWANgARLEAQoWVmVyaWZ5RGF0YXNvdXJjZURvbWFpbhIvLnNhYXMuYWNjb3VudHMudjEuVmVyaWZ5RGF0YXNvdXJjZURvbWFpblJlcXVlc3QaMC5zYWFzLmFjY291bnRzLnYxLlZlcmlmeURhdGFzb3VyY2VEb21haW5SZXNwb25zZSJHwvMYQwgCEAQqDAoGb3JnX2lkEAIYATABOiMKH3NhYXMuZGF0YXNvdXJjZS5kb21haW5fdmVyaWZpZWQQAkABSARQAlgDYAESwwEKFkRlbGV0ZURhdGFzb3VyY2VEb21haW4SLy5zYWFzLmFjY291bnRzLnYxLkRlbGV0ZURhdGFzb3VyY2VEb21haW5SZXF1ZXN0GjAuc2Fhcy5hY2NvdW50cy52MS5EZWxldGVEYXRhc291cmNlRG9tYWluUmVzcG9uc2UiRsLzGEIIAhAEKgwKBm9yZ19pZBACGAEwAToiCh5zYWFzLmRhdGFzb3VyY2UuZG9tYWluX3JlbW92ZWQQAkABSARQAlgDYAESoAEKFUxpc3RTb3VyY2VEZWxlZ2F0aW9ucxIuLnNhYXMuYWNjb3VudHMudjEuTGlzdFNvdXJjZURlbGVnYXRpb25zUmVxdWVzdBovLnNhYXMuYWNjb3VudHMudjEuTGlzdFNvdXJjZURlbGVnYXRpb25zUmVzcG9uc2UiJsLzGCIIAhAEKgwKBm9yZ19pZBACGAEwAToCEAFAAUgDUAJYA2ABEscBChZSZXZva2VTb3VyY2VEZWxlZ2F0aW9uEi8uc2Fhcy5hY2NvdW50cy52MS5SZXZva2VTb3VyY2VEZWxlZ2F0aW9uUmVxdWVzdBowLnNhYXMuYWNjb3VudHMudjEuUmV2b2tlU291cmNlRGVsZWdhdGlvblJlc3BvbnNlIkrC8xhGCAIQBCoMCgZvcmdfaWQQAhgBMAE6Jgoic2Fhcy5kYXRhc291cmNlLmRlbGVnYXRpb24ucmV2b2tlZBACQAFIBFACWANgARLrAQoWRGVsZWdhdGVTb3VyY2VUcmlnZ2VycxIvLnNhYXMuYWNjb3VudHMudjEuRGVsZWdhdGVTb3VyY2VUcmlnZ2Vyc1JlcXVlc3QaMC5zYWFzLmFjY291bnRzLnYxLkRlbGVnYXRlU291cmNlVHJpZ2dlcnNSZXNwb25zZSJuwvMYaggCEAQqDAoGb3JnX2lkEAIYATABOkoKInNhYXMuZGF0YXNvdXJjZS5kZWxlZ2F0aW9uLmNyZWF0ZWQKInNhYXMuZGF0YXNvdXJjZS5kZWxlZ2F0aW9uLnJldm9rZWQQAkABSARQAlgDYAESsgEKG0dldFNvdXJjZVRyaWdnZXJEZWNsYXJhdGlvbhI0LnNhYXMuYWNjb3VudHMudjEuR2V0U291cmNlVHJpZ2dlckRlY2xhcmF0aW9uUmVxdWVzdBo1LnNhYXMuYWNjb3VudHMudjEuR2V0U291cmNlVHJpZ2dlckRlY2xhcmF0aW9uUmVzcG9uc2UiJsLzGCIIAhAEKgwKBm9yZ19pZBACGAEwAToCEAFAAUgDUAJYA2ABEoMDCg9SZWNvbmNpbGVTb3VyY2USKC5zYWFzLmFjY291bnRzLnYxLlJlY29uY2lsZVNvdXJjZVJlcXVlc3QaKS5zYWFzLmFjY291bnRzLnYxLlJlY29uY2lsZVNvdXJjZVJlc3BvbnNlIpoCwvMYaggCEAMaEWRhdGFzb3VyY2U6aW52b2tlIhFkYXRhc291cmNlOmludm9rZSoPCglzb3VyY2VfaWQQBBgDMAE6IQodc2Fhcy5kYXRhc291cmNlLnNvdXJjZS5zeW5jZWQQAkADSARQAlgCYAHa8xinAQoCCDwSAwjYBBgFIgIIAioLVU5BVkFJTEFCTEUqElJFU09VUkNFX0VYSEFVU1RFRCoHQUJPUlRFRDIPc2Fhcy1kYXRhc291cmNlSjkvc2Fhcy5hY2NvdW50cy52MS5EYXRhc291cmNlU2VydmljZS9Mb29rdXBSZWNvbmNpbGVTb3VyY2VQgCBYgCBgAXIYCgZzb3VyY2USBmludm9rZRIEcmVhZBgBEr8BChVMb29rdXBSZWNvbmNpbGVTb3VyY2USLi5zYWFzLmFjY291bnRzLnYxLkxvb2t1cFJlY29uY2lsZVNvdXJjZVJlcXVlc3QaKS5zYWFzLmFjY291bnRzLnYxLlJlY29uY2lsZVNvdXJjZVJlc3BvbnNlIkvC8xhHCAIQAxoPZGF0YXNvdXJjZTpyZWFkIg9kYXRhc291cmNlOnJlYWQqDwoJZWZmZWN0X2lkEAQYAzABOgIQAUABSANQAlgCYAFiBnByb3RvMw", [file_buf_validate_validate, file_codefly_runnable_v0_options, file_google_api_annotations, file_google_protobuf_duration, file_google_protobuf_struct, file_google_protobuf_timestamp, file_saas_jobs_v1_jobs, file_saas_policy_v1_options]); /** * GitHubDatasourceConfig is the public, non-secret configuration of a GitHub @@ -281,10 +281,40 @@ export type UploadDatasourceConfig = Message<"saas.accounts.v1.UploadDatasourceC /** * Upper bound on objects fetched per sync. Zero applies the connector default. + * Only objects that pass the filters below count against it. * * @generated from field: uint32 max_objects = 6; */ maxObjects: number; + + /** + * Which keys under the prefix are ingested. Each pattern is an RE2 regular + * expression matched against the object key with the prefix removed, + * unanchored and case-sensitive (anchor with ^ and $, fold case with (?i)). + * An empty include admits every key; an exclude match always wins. Applied + * when the prefix is listed, before anything is fetched, so an excluded + * object is never read and never counts against max_objects. An invalid + * expression is refused when the source is added or updated, never at sync: + * the call fails INVALID_ARGUMENT with a google.rpc.BadRequest violation, and + * a message that starts with the field's path, e.g. + * "upload.include_patterns[1]: …". + * + * @generated from field: repeated string include_patterns = 7; + */ + includePatterns: string[]; + + /** + * @generated from field: repeated string exclude_patterns = 8; + */ + excludePatterns: string[]; + + /** + * Case-insensitive key suffixes, e.g. [".md", ".pdf"], intersected with the + * patterns. Empty admits every type. GitHub's file_extensions semantics. + * + * @generated from field: repeated string file_extensions = 9; + */ + fileExtensions: string[]; }; /** @@ -771,6 +801,20 @@ export type UpdateSourceRequest = Message<"saas.accounts.v1.UpdateSourceRequest" * @generated from field: saas.accounts.v1.SourceFetchScope expected = 6; */ expected?: SourceFetchScope; + + /** + * An object-storage source's key patterns (see UploadDatasourceConfig), + * replaced whole like every field here. Its file_extensions is field 5; its + * branch and paths must be empty. Refused for a github source. + * + * @generated from field: repeated string include_patterns = 7; + */ + includePatterns: string[]; + + /** + * @generated from field: repeated string exclude_patterns = 8; + */ + excludePatterns: string[]; }; /** @@ -808,6 +852,16 @@ export type SourceFetchScope = Message<"saas.accounts.v1.SourceFetchScope"> & { * @generated from field: repeated string file_extensions = 3; */ fileExtensions: string[]; + + /** + * @generated from field: repeated string include_patterns = 4; + */ + includePatterns: string[]; + + /** + * @generated from field: repeated string exclude_patterns = 5; + */ + excludePatterns: string[]; }; /** diff --git a/module/services/frontend/code/packages/saas-sdk/package.json b/module/services/frontend/code/packages/saas-sdk/package.json index bddb526ba..485344fa3 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.35", + "version": "0.3.36", "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/packages/saas-ui/README.md b/module/services/frontend/code/packages/saas-ui/README.md index 428c2bcca..31e6aaee6 100644 --- a/module/services/frontend/code/packages/saas-ui/README.md +++ b/module/services/frontend/code/packages/saas-ui/README.md @@ -385,9 +385,19 @@ const directory = usePrincipalDirectory(); // loading | ready | refused | failed in its own shell — a line in a header, say — without re-deriving them from the phase names and getting the terminal cases wrong. `` is the card, and `useSourceSync(client, orgId, source)` the read. +- `` — endpoint (https only), + region, bucket, prefix, the key filter (include and exclude RE2 patterns, one + per line, over the key after the prefix; file types as on GitHub), access key + id, the secret access key the host encrypts at receipt, and the target + collection. It states that a bucket has no per-object access list, so one + source is one collection with one set of readers. A host refusal of a pattern + leads with the field it names and is shown under that field. Map its values + with `objectStorageInputFrom(orgId, values)` for the client's optional + `addObjectStorageSource`. Nothing in the kit mounts it yet: the host refuses + new object-storage sources until the provider is conformant (#1211). - Hooks over a `DatasourceClient`: `useListSources`, `useAddGitHubSource`, - `useUpdateSource`, `useSyncSource`, `useDeleteSource`, `useAccessibleScopes`, - `useMyAccountLinks`. + `useAddObjectStorageSource`, `useUpdateSource`, `useSyncSource`, + `useDeleteSource`, `useAccessibleScopes`, `useMyAccountLinks`. - ``, behind the row's **Edit**, changes what a connected source fetches: its branch and its path and file-type filters, bound by the same schema the connect form uses. It offers the connect @@ -629,8 +639,8 @@ offer a Markdown preset. The host intersects suffixes with paths for snapshots and incremental changes; empty keeps all file types. The filter is selected at connection time and does not rewrite existing sources. -The SDK peer floor is **0.3.35**: the datasource client maps an `api` source's -credential kind and OAuth grant, and declares operations, with enums the SDK -exports from that release (principal-name resolution needs 0.3.14's directory -service). Earlier SDK versions must be rejected during installation rather than +The SDK peer floor is **0.3.36**: the object-storage connect form sends an +`upload` source's include and exclude key patterns, fields the SDK carries from +that release (the `api` source's credential kind, OAuth grant and operations +need 0.3.35; principal-name resolution needs 0.3.14's directory service). Earlier SDK versions must be rejected during installation rather than accepted and then failing when the component loads. diff --git a/module/services/frontend/code/packages/saas-ui/package.json b/module/services/frontend/code/packages/saas-ui/package.json index 89b66f66f..dba683525 100644 --- a/module/services/frontend/code/packages/saas-ui/package.json +++ b/module/services/frontend/code/packages/saas-ui/package.json @@ -1,6 +1,6 @@ { "name": "@codefly-dev/saas-ui", - "version": "0.12.15", + "version": "0.12.16", "description": "Reusable SaaS-domain frontend components (datasources, …) the portal and solutions both import, built on @codefly-dev/ui primitives and driven by an injected typed client.", "repository": { "type": "git", @@ -41,7 +41,7 @@ }, "peerDependencies": { "@bufbuild/protobuf": ">=2", - "@codefly-dev/saas-sdk": "^0.3.35", + "@codefly-dev/saas-sdk": "^0.3.36", "@connectrpc/connect": ">=2", "@connectrpc/connect-web": ">=2", "@hookform/resolvers": ">=3", @@ -53,7 +53,7 @@ }, "devDependencies": { "@bufbuild/protobuf": "^2.11.0", - "@codefly-dev/saas-sdk": "^0.3.35", + "@codefly-dev/saas-sdk": "^0.3.36", "@connectrpc/connect": "^2.1.1", "@connectrpc/connect-web": "^2.1.1", "@hookform/resolvers": "^3.9.0", diff --git a/module/services/frontend/code/packages/saas-ui/src/datasources/__tests__/connect-object-storage-form.test.tsx b/module/services/frontend/code/packages/saas-ui/src/datasources/__tests__/connect-object-storage-form.test.tsx new file mode 100644 index 000000000..2c43df225 --- /dev/null +++ b/module/services/frontend/code/packages/saas-ui/src/datasources/__tests__/connect-object-storage-form.test.tsx @@ -0,0 +1,103 @@ +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + ConnectObjectStorageForm, + objectStorageInputFrom, +} from "../connect-object-storage-form.js"; + +afterEach(cleanup); + +function fill(label: string, value: string) { + fireEvent.change(screen.getByLabelText(label), { target: { value } }); +} + +function fillRequired() { + fill("Endpoint", "https://store.example.com"); + fill("Region", "us-east-1"); + fill("Bucket", "acme-docs"); + fill("Access key ID", "AKIAEXAMPLE"); + fill("Secret access key", "example-secret"); + fill("Target collection", "Handbook"); +} + +describe("ConnectObjectStorageForm", () => { + it("takes key patterns and file types, one pattern per line", async () => { + const onSubmit = vi.fn(); + render( + {}} + isPending={false} + />, + ); + fillRequired(); + fill("Prefix (optional)", "kb/"); + // A pattern may hold a comma; only a line ends one. + fill("Include keys matching (optional)", "^guides/\n\\.md$\n^v{1,3}/"); + fill("Exclude keys matching (optional)", "^drafts/"); + fill("File types (optional)", ".MD, .pdf"); + fireEvent.click(screen.getByRole("button", { name: "Connect bucket" })); + + await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1)); + expect(objectStorageInputFrom("org-1", onSubmit.mock.calls[0][0])).toEqual({ + orgId: "org-1", + endpoint: "https://store.example.com", + region: "us-east-1", + bucket: "acme-docs", + prefix: "kb/", + includePatterns: ["^guides/", "\\.md$", "^v{1,3}/"], + excludePatterns: ["^drafts/"], + fileExtensions: [".md", ".pdf"], + accessKeyId: "AKIAEXAMPLE", + secretAccessKey: "example-secret", + targetCollection: "Handbook", + boundaryNodeId: undefined, + }); + }); + + it("shows the host's refusal of a pattern under the field it names", () => { + const refusal = + 'upload.exclude_patterns[0]: "^(?!drafts/)" is not a valid RE2 expression: invalid or unsupported Perl syntax: `(?!`'; + render( + {}} + onCancel={() => {}} + isPending={false} + errorMessage={refusal} + />, + ); + const field = screen.getByLabelText("Exclude keys matching (optional)"); + expect(field.getAttribute("aria-invalid")).toBe("true"); + const described = document.getElementById( + field.getAttribute("aria-describedby") ?? "", + ); + expect(described?.textContent).toBe(refusal); + // Placed once, under its field: not repeated in the form's own slot. + expect(screen.getAllByText(refusal)).toHaveLength(1); + expect( + screen + .getByLabelText("Include keys matching (optional)") + .getAttribute("aria-invalid"), + ).toBe("false"); + }); + + it("keeps a refusal that names no key-filter field in the form's own slot", () => { + render( + {}} + onCancel={() => {}} + isPending={false} + errorMessage="the store refused the access key" + />, + ); + expect(screen.getByRole("alert").textContent).toBe( + "the store refused the access key", + ); + }); +}); diff --git a/module/services/frontend/code/packages/saas-ui/src/datasources/__tests__/gateway.test.tsx b/module/services/frontend/code/packages/saas-ui/src/datasources/__tests__/gateway.test.tsx index c9114c216..622078f32 100644 --- a/module/services/frontend/code/packages/saas-ui/src/datasources/__tests__/gateway.test.tsx +++ b/module/services/frontend/code/packages/saas-ui/src/datasources/__tests__/gateway.test.tsx @@ -177,6 +177,20 @@ describe("createDatasourceClient", () => { expected: { branch: "main", paths: [], fileExtensions: [] }, }), deleteSource: () => client.deleteSource("org-1", "ds-1"), + addObjectStorageSource: () => + client.addObjectStorageSource!({ + orgId: "org-1", + endpoint: "https://store.example.com", + region: "us-east-1", + bucket: "acme-docs", + prefix: "kb/", + includePatterns: ["^guides/"], + excludePatterns: [], + fileExtensions: [], + accessKeyId: "AKIAEXAMPLE", + secretAccessKey: "secret", + targetCollection: "Example", + }), // The stub answers {} to everything; an operation whose answer must // carry a record rejects on it, after the call this test counts. beginAccountLink: () => diff --git a/module/services/frontend/code/packages/saas-ui/src/datasources/collection-field.tsx b/module/services/frontend/code/packages/saas-ui/src/datasources/collection-field.tsx new file mode 100644 index 000000000..ca8a2f39b --- /dev/null +++ b/module/services/frontend/code/packages/saas-ui/src/datasources/collection-field.tsx @@ -0,0 +1,112 @@ +"use client"; + +import { Input, Label } from "@codefly-dev/ui/layout"; +import type { UseFormRegisterReturn } from "react-hook-form"; +import { fieldErrorClass as errorClass } from "./credential-mode.js"; +import type { CollectionAccessView } from "./types.js"; + +interface CollectionFieldProps { + idFor: (name: string) => string; + collections?: CollectionAccessView[]; + readableNodeIds?: string[]; + collectionError?: boolean; + boundaryNodeId?: string; + targetCollection?: string; + /** An existing collection was picked, or "" to create one. */ + onSelect: (nodeId: string, label: string) => void; + inputProps: UseFormRegisterReturn; + error?: string; +} + +/** + * Where a new source's content lands: an existing collection, or a label to + * create one under. Every connect form shares it, so who can read the result + * is told the same way whichever provider is being connected. + */ +export function CollectionField({ + idFor, + collections, + readableNodeIds, + collectionError, + boundaryNodeId, + targetCollection, + onSelect, + inputProps, + error, +}: CollectionFieldProps) { + const selected = collections?.find( + (collection) => collection.nodeId === boundaryNodeId, + ); + const named = + selected ?? + collections?.find((collection) => collection.label === targetCollection); + return ( +
+ {collections && ( + <> + + + + )} + + +

+ Connecting grants no read access to the creator or a default team. + Organization administrators explicitly grant members or teams read + access. Platform administrators read every collection without a grant. +

+ {collectionError ? ( +

Couldn’t inspect collection grants.

+ ) : named ? ( +

+ Readers:{" "} + {named.grants.map((grant) => grant.subjectLabel).join(", ") || + "No collection read grants"} + .{" "} + {readableNodeIds === undefined + ? "Your read permission is unresolved." + : readableNodeIds.includes(named.nodeId) + ? "You can read this collection." + : "You do not have read access. Ask an administrator for access."} +

+ ) : ( +

+ {collections + ? "New collections have no read grants. Your read permission must be checked after creation." + : "Collection grants are unresolved. Inspect them in the host after connecting."} +

+ )} + {error && ( +

+ {error} +

+ )} +
+ ); +} diff --git a/module/services/frontend/code/packages/saas-ui/src/datasources/connect-github-form.tsx b/module/services/frontend/code/packages/saas-ui/src/datasources/connect-github-form.tsx index 88f35524c..8d9cdc20b 100644 --- a/module/services/frontend/code/packages/saas-ui/src/datasources/connect-github-form.tsx +++ b/module/services/frontend/code/packages/saas-ui/src/datasources/connect-github-form.tsx @@ -4,6 +4,7 @@ import { Button, Input, Label, Textarea } from "@codefly-dev/ui/layout"; import { zodResolver } from "@hookform/resolvers/zod"; import { useId } from "react"; import { useForm, useWatch } from "react-hook-form"; +import { CollectionField } from "./collection-field.js"; import { AccessTokenField, AppInstallPrompt, @@ -78,12 +79,6 @@ export function ConnectGitHubForm({ control: form.control, name: "targetCollection", }); - const selected = collections?.find( - (collection) => collection.nodeId === boundaryNodeId, - ); - const named = - selected ?? - collections?.find((collection) => collection.label === targetCollection); return (
-
- {collections && ( - <> - - - - )} - - -

- Connecting grants no read access to the creator or a default team. - Organization administrators explicitly grant members or teams read - access. Platform administrators read every collection without a - grant. -

- {collectionError ? ( -

Couldn’t inspect collection grants.

- ) : named ? ( -

- Readers:{" "} - {named.grants.map((grant) => grant.subjectLabel).join(", ") || - "No collection read grants"} - .{" "} - {readableNodeIds === undefined - ? "Your read permission is unresolved." - : readableNodeIds.includes(named.nodeId) - ? "You can read this collection." - : "You do not have read access. Ask an administrator for access."} -

- ) : ( -

- {collections - ? "New collections have no read grants. Your read permission must be checked after creation." - : "Collection grants are unresolved. Inspect them in the host after connecting."} -

- )} - {errors.targetCollection && ( -

- {errors.targetCollection.message} -

- )} -
+ { + form.setValue("boundaryNodeId", nodeId); + form.setValue("targetCollection", label); + }} + inputProps={form.register("targetCollection")} + {...(errors.targetCollection?.message + ? { error: errors.targetCollection.message } + : {})} + /> {method === "pat" && ( + value.toLowerCase(), + ), + accessKeyId: values.accessKeyId, + secretAccessKey: values.secretAccessKey, + targetCollection: values.targetCollection, + boundaryNodeId: values.boundaryNodeId || undefined, + }; +} + +interface ConnectObjectStorageFormProps { + collections?: CollectionAccessView[]; + readableNodeIds?: string[]; + collectionError?: boolean; + onSubmit: (values: ConnectObjectStorageValues) => void; + onCancel: () => void; + isPending: boolean; + errorMessage?: string; +} + +const textFields = [ + { + name: "endpoint", + label: "Endpoint", + placeholder: "https://s3.us-east-1.amazonaws.com", + }, + { name: "region", label: "Region", placeholder: "us-east-1" }, + { name: "bucket", label: "Bucket", placeholder: "acme-knowledge" }, + { name: "prefix", label: "Prefix (optional)", placeholder: "handbook/" }, + { name: "accessKeyId", label: "Access key ID", placeholder: "" }, +] as const; + +export function ConnectObjectStorageForm({ + collections, + readableNodeIds, + collectionError, + onSubmit, + onCancel, + isPending, + errorMessage, +}: ConnectObjectStorageFormProps) { + const fieldId = useId(); + const idFor = (name: string) => `${fieldId}-${name}`; + const form = useForm({ + resolver: zodResolver(connectObjectStorageSchema), + defaultValues: { + endpoint: "", + region: "", + bucket: "", + prefix: "", + includePatterns: "", + excludePatterns: "", + fileExtensions: "", + accessKeyId: "", + secretAccessKey: "", + boundaryNodeId: "", + targetCollection: "", + }, + }); + const { errors } = form.formState; + // The host refuses an invalid pattern by naming its field; that refusal is + // shown under the field, and only an unplaced one in the form's own slot. + const refusedField = refusedKeyFilterField(errorMessage); + const filterError = ( + name: "includePatterns" | "excludePatterns" | "fileExtensions", + ) => + errors[name]?.message ?? (refusedField === name ? errorMessage : undefined); + const filters = [ + { + name: "includePatterns", + label: "Include keys matching (optional)", + help: "One RE2 pattern per line, matched against the key after the prefix, e.g. ^guides/ or \\.md$. Empty includes every key.", + }, + { + name: "excludePatterns", + label: "Exclude keys matching (optional)", + help: "One RE2 pattern per line. An excluded key is never fetched, even if it is also included.", + }, + ] as const; + const boundaryNodeId = useWatch({ + control: form.control, + name: "boundaryNodeId", + }); + const targetCollection = useWatch({ + control: form.control, + name: "targetCollection", + }); + + return ( +
+
+
+

Connect object storage

+

+ Register a bucket as a data source. The objects under its prefix + that pass its key filter are pulled on sync. Everyone who may read + the collection reads every one of them: a bucket has no per-object + access list, so the bucket, prefix and filter make one collection + with one set of readers. For another audience, connect another + source with its own filter. +

+
+ +
+ {textFields.map((field) => { + const error = errors[field.name]?.message; + return ( +
+ + + {error && ( +

+ {error} +

+ )} +
+ ); + })} + + {filters.map((field) => { + const error = filterError(field.name); + return ( +
+ +