@elevasis/core 0.7.1 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/README.md +41 -41
- package/src/__tests__/publish.test.ts +18 -18
- package/src/__tests__/{template-foundations-compatibility.test.ts → template-core-compatibility.test.ts} +99 -99
- package/src/_gen/__tests__/__snapshots__/contracts.md.snap +1135 -1131
- package/src/_gen/__tests__/scaffold-contracts.test.ts +53 -53
- package/src/_gen/scaffold-contracts.ts +45 -45
- package/src/auth/multi-tenancy/credentials/README.md +38 -38
- package/src/auth/multi-tenancy/credentials/index.ts +6 -6
- package/src/auth/multi-tenancy/credentials/server/encryption.ts +39 -39
- package/src/auth/multi-tenancy/credentials/server/service.ts +60 -60
- package/src/auth/multi-tenancy/index.ts +17 -17
- package/src/auth/multi-tenancy/invitations/api-schemas.ts +107 -107
- package/src/auth/multi-tenancy/invitations/index.ts +37 -37
- package/src/auth/multi-tenancy/invitations/invitation.ts +86 -86
- package/src/auth/multi-tenancy/invitations/server/index.ts +25 -25
- package/src/auth/multi-tenancy/invitations/server/transforms.ts +24 -24
- package/src/auth/multi-tenancy/invitations/server/workos.ts +24 -24
- package/src/auth/multi-tenancy/invitations/supabase.ts +50 -50
- package/src/auth/multi-tenancy/memberships/api-schemas.ts +126 -126
- package/src/auth/multi-tenancy/memberships/index.ts +21 -21
- package/src/auth/multi-tenancy/memberships/membership.ts +138 -138
- package/src/auth/multi-tenancy/memberships/server/index.ts +15 -15
- package/src/auth/multi-tenancy/memberships/server/transforms.ts +32 -32
- package/src/auth/multi-tenancy/memberships/server/workos.ts +21 -21
- package/src/auth/multi-tenancy/memberships/supabase.ts +46 -46
- package/src/auth/multi-tenancy/organizations/api-schemas.ts +128 -128
- package/src/auth/multi-tenancy/organizations/index.ts +23 -23
- package/src/auth/multi-tenancy/organizations/organization.ts +24 -24
- package/src/auth/multi-tenancy/organizations/server/index.ts +10 -10
- package/src/auth/multi-tenancy/organizations/server/transforms.ts +35 -35
- package/src/auth/multi-tenancy/organizations/server/workos.ts +20 -20
- package/src/auth/multi-tenancy/types.ts +83 -83
- package/src/auth/multi-tenancy/users/api-schemas.ts +194 -194
- package/src/auth/multi-tenancy/users/index.ts +27 -27
- package/src/auth/multi-tenancy/users/server/index.ts +19 -19
- package/src/auth/multi-tenancy/users/server/transforms.ts +21 -21
- package/src/auth/multi-tenancy/users/server/workos.ts +16 -16
- package/src/auth/multi-tenancy/users/user.ts +65 -65
- package/src/business/README.md +52 -52
- package/src/business/__tests__/entities-published.test.ts +33 -33
- package/src/business/acquisition/api-schemas.ts +759 -759
- package/src/business/acquisition/index.ts +109 -109
- package/src/business/acquisition/types.ts +402 -402
- package/src/business/base-entities.test.ts +481 -481
- package/src/business/base-entities.ts +241 -241
- package/src/business/entities-published.ts +24 -24
- package/src/business/index.ts +15 -15
- package/src/business/pdf/browser/pdfmake-browser.ts +229 -229
- package/src/business/pdf/index.ts +10 -10
- package/src/business/pdf/server/index.ts +21 -21
- package/src/business/pdf/server/themes/default.ts +8 -8
- package/src/business/pdf/server/themes/index.ts +9 -9
- package/src/business/pdf/server/themes/types.ts +8 -8
- package/src/business/pdf/types.ts +272 -272
- package/src/business/projects/index.ts +2 -2
- package/src/business/projects/sse-events.ts +21 -21
- package/src/business/projects/types.ts +89 -89
- package/src/business/sales/api-schemas.ts +75 -75
- package/src/business/seo/__tests__/linking.test.ts +549 -549
- package/src/business/seo/__tests__/types.test.ts +404 -404
- package/src/business/seo/index.ts +2 -2
- package/src/business/seo/linking.ts +281 -281
- package/src/business/seo/types.ts +199 -199
- package/src/commands/queue/index.ts +3 -3
- package/src/commands/queue/schemas.test.ts +593 -593
- package/src/commands/queue/schemas.ts +125 -125
- package/src/commands/queue/sse-events.ts +61 -61
- package/src/commands/queue/types/action.ts +52 -52
- package/src/commands/queue/types/checkpoint.ts +44 -44
- package/src/commands/queue/types/index.ts +7 -7
- package/src/commands/queue/types/task.ts +116 -116
- package/src/commands/queue/types.ts +14 -14
- package/src/content/distribution-metadata.ts +61 -61
- package/src/content/index.ts +10 -10
- package/src/deployments/index.ts +22 -22
- package/src/execution/core/__tests__/archived-logs.test.ts +72 -72
- package/src/execution/core/index.ts +11 -11
- package/src/execution/core/runner-types.ts +80 -80
- package/src/execution/core/server/environment.ts +31 -31
- package/src/execution/core/sse-executions.ts +119 -119
- package/src/execution/core/types.ts +29 -29
- package/src/execution/engine/__tests__/fixtures/test-agents.ts +4 -4
- package/src/execution/engine/__tests__/timeout.test.ts +565 -565
- package/src/execution/engine/agent/__tests__/errors.test.ts +508 -508
- package/src/execution/engine/agent/actions/__tests__/processor.test.ts +531 -531
- package/src/execution/engine/agent/actions/executor.ts +205 -205
- package/src/execution/engine/agent/actions/navigate-knowledge-executor.ts +230 -230
- package/src/execution/engine/agent/actions/processor.ts +116 -116
- package/src/execution/engine/agent/actions/types.ts +70 -70
- package/src/execution/engine/agent/core/agent.ts +810 -810
- package/src/execution/engine/agent/core/types.ts +155 -155
- package/src/execution/engine/agent/errors.ts +251 -251
- package/src/execution/engine/agent/index.ts +78 -78
- package/src/execution/engine/agent/knowledge-map/types.ts +106 -106
- package/src/execution/engine/agent/knowledge-map/utils.ts +101 -101
- package/src/execution/engine/agent/memory/__tests__/manager.test.ts +754 -754
- package/src/execution/engine/agent/memory/domains.ts +99 -99
- package/src/execution/engine/agent/memory/manager.ts +365 -365
- package/src/execution/engine/agent/memory/processor.ts +66 -66
- package/src/execution/engine/agent/memory/types.ts +90 -90
- package/src/execution/engine/agent/memory/utils.ts +134 -134
- package/src/execution/engine/agent/observability/logging.ts +467 -467
- package/src/execution/engine/agent/observability/types.ts +64 -64
- package/src/execution/engine/agent/reasoning/adapters/agent-adapter-helpers.ts +349 -349
- package/src/execution/engine/agent/reasoning/processor.ts +92 -92
- package/src/execution/engine/agent/reasoning/prompt-sections/base-actions.ts +134 -134
- package/src/execution/engine/agent/reasoning/prompt-sections/completion.ts +49 -49
- package/src/execution/engine/agent/reasoning/prompt-sections/knowledge-map.ts +93 -93
- package/src/execution/engine/agent/reasoning/prompt-sections/memory.ts +65 -65
- package/src/execution/engine/agent/reasoning/prompt-sections/tools.ts +44 -44
- package/src/execution/engine/agent/reasoning/request-builder.ts +169 -169
- package/src/execution/engine/agent/reasoning/types.ts +18 -18
- package/src/execution/engine/base/errors.ts +118 -118
- package/src/execution/engine/base/index.ts +2 -2
- package/src/execution/engine/base/logging.ts +31 -31
- package/src/execution/engine/base/serialization.ts +324 -324
- package/src/execution/engine/base/types.ts +126 -126
- package/src/execution/engine/base/utils.ts +41 -41
- package/src/execution/engine/index.ts +434 -434
- package/src/execution/engine/interface/index.ts +1 -1
- package/src/execution/engine/interface/types.ts +62 -62
- package/src/execution/engine/llm/__tests__/model-info.test.ts +50 -50
- package/src/execution/engine/llm/__tests__/model-validation.test.ts +321 -321
- package/src/execution/engine/llm/__tests__/response-schema-validator.test.ts +115 -115
- package/src/execution/engine/llm/adapters/__tests__/adapter-factory.test.ts +375 -375
- package/src/execution/engine/llm/adapters/__tests__/anthropic-adapter.test.ts +463 -463
- package/src/execution/engine/llm/adapters/__tests__/anthropic.integration.test.ts +177 -177
- package/src/execution/engine/llm/adapters/__tests__/google-adapter.test.ts +722 -722
- package/src/execution/engine/llm/adapters/__tests__/google.integration.test.ts +376 -376
- package/src/execution/engine/llm/adapters/__tests__/openai-adapter.test.ts +551 -551
- package/src/execution/engine/llm/adapters/__tests__/openrouter-adapter.test.ts +563 -563
- package/src/execution/engine/llm/adapters/__tests__/openrouter.integration.test.ts +105 -105
- package/src/execution/engine/llm/adapters/__tests__/universal-adapter.test.ts +537 -537
- package/src/execution/engine/llm/adapters/circuit-breaker.ts +147 -147
- package/src/execution/engine/llm/adapters/index.ts +17 -17
- package/src/execution/engine/llm/adapters/mock-adapter.ts +116 -116
- package/src/execution/engine/llm/adapters/server/adapter-factory.ts +130 -130
- package/src/execution/engine/llm/adapters/server/anthropic.ts +137 -137
- package/src/execution/engine/llm/adapters/server/google.ts +283 -283
- package/src/execution/engine/llm/adapters/server/index.ts +12 -12
- package/src/execution/engine/llm/adapters/server/openai.ts +206 -206
- package/src/execution/engine/llm/adapters/server/openrouter.ts +235 -235
- package/src/execution/engine/llm/adapters/universal-adapter.ts +230 -230
- package/src/execution/engine/llm/errors.ts +186 -186
- package/src/execution/engine/llm/model-info.ts +332 -332
- package/src/execution/engine/llm/response-schema-validator.ts +113 -113
- package/src/execution/engine/llm/types.ts +86 -86
- package/src/execution/engine/test-utils/index.ts +6 -6
- package/src/execution/engine/test-utils/mocks.ts +56 -56
- package/src/execution/engine/tools/integration/base-integration-adapter.ts +50 -50
- package/src/execution/engine/tools/integration/index.ts +53 -53
- package/src/execution/engine/tools/integration/server/adapters/anymailfinder/anymailfinder-adapter.ts +73 -73
- package/src/execution/engine/tools/integration/server/adapters/anymailfinder/anymailfinder-tools.ts +209 -209
- package/src/execution/engine/tools/integration/server/adapters/anymailfinder/fetch/find-company-email/index.ts +82 -82
- package/src/execution/engine/tools/integration/server/adapters/anymailfinder/fetch/find-decision-maker-email/index.ts +122 -122
- package/src/execution/engine/tools/integration/server/adapters/anymailfinder/fetch/find-person-email/index.ts +89 -89
- package/src/execution/engine/tools/integration/server/adapters/anymailfinder/fetch/verify-email/index.ts +84 -84
- package/src/execution/engine/tools/integration/server/adapters/anymailfinder/index.ts +16 -16
- package/src/execution/engine/tools/integration/server/adapters/apify/__tests__/apify-run-actor.integration.test.ts +293 -293
- package/src/execution/engine/tools/integration/server/adapters/apify/apify-adapter.ts +100 -100
- package/src/execution/engine/tools/integration/server/adapters/apify/apify-tools.ts +217 -217
- package/src/execution/engine/tools/integration/server/adapters/apify/fetch/get-dataset-items/index.ts +92 -92
- package/src/execution/engine/tools/integration/server/adapters/apify/fetch/run-actor/index.ts +218 -218
- package/src/execution/engine/tools/integration/server/adapters/apify/fetch/start-actor/index.ts +87 -87
- package/src/execution/engine/tools/integration/server/adapters/apify/index.ts +11 -11
- package/src/execution/engine/tools/integration/server/adapters/attio/__tests__/attio-crud.integration.test.ts +361 -361
- package/src/execution/engine/tools/integration/server/adapters/attio/attio-adapter.ts +162 -162
- package/src/execution/engine/tools/integration/server/adapters/attio/attio-tools.ts +594 -594
- package/src/execution/engine/tools/integration/server/adapters/attio/fetch/create-attribute/index.ts +214 -214
- package/src/execution/engine/tools/integration/server/adapters/attio/fetch/create-note/index.ts +152 -152
- package/src/execution/engine/tools/integration/server/adapters/attio/fetch/create-record/index.ts +141 -141
- package/src/execution/engine/tools/integration/server/adapters/attio/fetch/delete-note/index.ts +86 -86
- package/src/execution/engine/tools/integration/server/adapters/attio/fetch/delete-record/index.ts +105 -105
- package/src/execution/engine/tools/integration/server/adapters/attio/fetch/get-record/index.ts +118 -118
- package/src/execution/engine/tools/integration/server/adapters/attio/fetch/list-attributes/index.ts +165 -165
- package/src/execution/engine/tools/integration/server/adapters/attio/fetch/list-notes/index.ts +96 -96
- package/src/execution/engine/tools/integration/server/adapters/attio/fetch/list-objects/index.ts +104 -104
- package/src/execution/engine/tools/integration/server/adapters/attio/fetch/list-records/index.ts +156 -156
- package/src/execution/engine/tools/integration/server/adapters/attio/fetch/update-attribute/index.ts +220 -220
- package/src/execution/engine/tools/integration/server/adapters/attio/fetch/update-record/index.ts +140 -140
- package/src/execution/engine/tools/integration/server/adapters/attio/fetch/utils/types.ts +146 -146
- package/src/execution/engine/tools/integration/server/adapters/attio/index.ts +31 -31
- package/src/execution/engine/tools/integration/server/adapters/gmail/gmail-adapter.ts +210 -210
- package/src/execution/engine/tools/integration/server/adapters/gmail/gmail-tools.ts +104 -104
- package/src/execution/engine/tools/integration/server/adapters/google-sheets/__tests__/google-sheets.integration.test.ts +261 -261
- package/src/execution/engine/tools/integration/server/adapters/google-sheets/google-sheets-adapter.ts +1189 -1189
- package/src/execution/engine/tools/integration/server/adapters/google-sheets/google-sheets-tools.ts +641 -641
- package/src/execution/engine/tools/integration/server/adapters/google-sheets/index.ts +18 -18
- package/src/execution/engine/tools/integration/server/adapters/instantly/fetch/activate-campaign/index.ts +86 -86
- package/src/execution/engine/tools/integration/server/adapters/instantly/fetch/add-to-campaign/__tests__/index.test.ts +289 -289
- package/src/execution/engine/tools/integration/server/adapters/instantly/fetch/add-to-campaign/index.ts +154 -154
- package/src/execution/engine/tools/integration/server/adapters/instantly/fetch/bulk-add-leads/__tests__/index.test.ts +325 -325
- package/src/execution/engine/tools/integration/server/adapters/instantly/fetch/bulk-add-leads/index.ts +153 -153
- package/src/execution/engine/tools/integration/server/adapters/instantly/fetch/bulk-delete-leads/index.ts +84 -84
- package/src/execution/engine/tools/integration/server/adapters/instantly/fetch/create-campaign/index.ts +125 -125
- package/src/execution/engine/tools/integration/server/adapters/instantly/fetch/create-inbox-test/index.ts +107 -107
- package/src/execution/engine/tools/integration/server/adapters/instantly/fetch/delete-campaign/index.ts +85 -85
- package/src/execution/engine/tools/integration/server/adapters/instantly/fetch/get-account-health/index.ts +91 -91
- package/src/execution/engine/tools/integration/server/adapters/instantly/fetch/get-campaign/index.ts +92 -92
- package/src/execution/engine/tools/integration/server/adapters/instantly/fetch/get-campaign-analytics/__tests__/index.test.ts +195 -195
- package/src/execution/engine/tools/integration/server/adapters/instantly/fetch/get-campaign-analytics/index.ts +113 -113
- package/src/execution/engine/tools/integration/server/adapters/instantly/fetch/get-daily-campaign-analytics/index.ts +104 -104
- package/src/execution/engine/tools/integration/server/adapters/instantly/fetch/get-emails/index.ts +155 -155
- package/src/execution/engine/tools/integration/server/adapters/instantly/fetch/get-step-analytics/__tests__/index.test.ts +196 -196
- package/src/execution/engine/tools/integration/server/adapters/instantly/fetch/get-step-analytics/index.ts +102 -102
- package/src/execution/engine/tools/integration/server/adapters/instantly/fetch/list-campaigns/__tests__/index.test.ts +189 -189
- package/src/execution/engine/tools/integration/server/adapters/instantly/fetch/list-campaigns/index.ts +87 -87
- package/src/execution/engine/tools/integration/server/adapters/instantly/fetch/list-leads/index.ts +112 -112
- package/src/execution/engine/tools/integration/server/adapters/instantly/fetch/patch-lead/index.ts +76 -76
- package/src/execution/engine/tools/integration/server/adapters/instantly/fetch/pause-campaign/index.ts +86 -86
- package/src/execution/engine/tools/integration/server/adapters/instantly/fetch/remove-from-subsequence/index.ts +98 -98
- package/src/execution/engine/tools/integration/server/adapters/instantly/fetch/send-reply/index.ts +126 -126
- package/src/execution/engine/tools/integration/server/adapters/instantly/fetch/update-campaign/__tests__/index.test.ts +193 -193
- package/src/execution/engine/tools/integration/server/adapters/instantly/fetch/update-campaign/index.ts +99 -99
- package/src/execution/engine/tools/integration/server/adapters/instantly/fetch/update-interest-status/__tests__/index.test.ts +621 -621
- package/src/execution/engine/tools/integration/server/adapters/instantly/fetch/update-interest-status/index.ts +125 -125
- package/src/execution/engine/tools/integration/server/adapters/instantly/index.ts +29 -29
- package/src/execution/engine/tools/integration/server/adapters/instantly/instantly-adapter.ts +178 -178
- package/src/execution/engine/tools/integration/server/adapters/instantly/instantly-tools.ts +1473 -1473
- package/src/execution/engine/tools/integration/server/adapters/millionverifier/fetch/check-credits/index.ts +59 -59
- package/src/execution/engine/tools/integration/server/adapters/millionverifier/fetch/verify-email/index.ts +102 -102
- package/src/execution/engine/tools/integration/server/adapters/millionverifier/index.ts +17 -17
- package/src/execution/engine/tools/integration/server/adapters/millionverifier/millionverifier-adapter.ts +80 -80
- package/src/execution/engine/tools/integration/server/adapters/millionverifier/millionverifier-tools.ts +102 -102
- package/src/execution/engine/tools/integration/server/adapters/resend/fetch/get-email/index.ts +102 -102
- package/src/execution/engine/tools/integration/server/adapters/resend/fetch/send-email/index.ts +134 -134
- package/src/execution/engine/tools/integration/server/adapters/resend/fetch/utils/types.ts +75 -75
- package/src/execution/engine/tools/integration/server/adapters/resend/index.ts +27 -27
- package/src/execution/engine/tools/integration/server/adapters/resend/resend-adapter.ts +108 -108
- package/src/execution/engine/tools/integration/server/adapters/resend/resend-tools.ts +132 -132
- package/src/execution/engine/tools/integration/server/adapters/signature-api/fetch/create-envelope/index.ts +274 -274
- package/src/execution/engine/tools/integration/server/adapters/signature-api/fetch/download-document/index.ts +230 -230
- package/src/execution/engine/tools/integration/server/adapters/signature-api/fetch/get-envelope/index.ts +133 -133
- package/src/execution/engine/tools/integration/server/adapters/signature-api/fetch/void-envelope/index.ts +90 -90
- package/src/execution/engine/tools/integration/server/adapters/stripe/fetch/utils/types.ts +210 -210
- package/src/execution/engine/tools/integration/server/adapters/stripe/stripe-adapter.ts +517 -517
- package/src/execution/engine/tools/integration/server/adapters/stripe/stripe-tools.ts +309 -309
- package/src/execution/engine/tools/integration/server/adapters/tomba/fetch/domain-search/index.ts +133 -133
- package/src/execution/engine/tools/integration/server/adapters/tomba/fetch/email-finder/index.ts +122 -122
- package/src/execution/engine/tools/integration/server/adapters/tomba/fetch/email-verifier/index.ts +111 -111
- package/src/execution/engine/tools/integration/server/adapters/tomba/index.ts +11 -11
- package/src/execution/engine/tools/integration/server/adapters/tomba/tomba-adapter.ts +78 -78
- package/src/execution/engine/tools/integration/server/adapters/tomba/tomba-tools.ts +222 -222
- package/src/execution/engine/tools/integration/server/index.ts +61 -61
- package/src/execution/engine/tools/integration/service.ts +161 -161
- package/src/execution/engine/tools/integration/tool.ts +253 -253
- package/src/execution/engine/tools/integration/types/anymailfinder.ts +74 -74
- package/src/execution/engine/tools/integration/types/apify.ts +92 -92
- package/src/execution/engine/tools/integration/types/index.ts +19 -19
- package/src/execution/engine/tools/integration/types/instantly.ts +557 -557
- package/src/execution/engine/tools/integration/types/millionverifier.ts +56 -56
- package/src/execution/engine/tools/integration/types/stripe.ts +162 -162
- package/src/execution/engine/tools/integration/types/tomba.ts +94 -94
- package/src/execution/engine/tools/lead-service-types.ts +884 -884
- package/src/execution/engine/tools/llm/index.ts +11 -11
- package/src/execution/engine/tools/llm/server/index.ts +8 -8
- package/src/execution/engine/tools/llm/server/llm-call-tool.ts +118 -118
- package/src/execution/engine/tools/platform/__tests__/pdf.test.ts +441 -441
- package/src/execution/engine/tools/platform/acquisition/company-tools.ts +248 -248
- package/src/execution/engine/tools/platform/acquisition/contact-tools.ts +319 -319
- package/src/execution/engine/tools/platform/acquisition/index.ts +43 -43
- package/src/execution/engine/tools/platform/acquisition/list-tools.ts +148 -148
- package/src/execution/engine/tools/platform/acquisition/types.ts +260 -260
- package/src/execution/engine/tools/platform/email/index.ts +122 -122
- package/src/execution/engine/tools/platform/email/types.ts +96 -96
- package/src/execution/engine/tools/platform/index.ts +157 -157
- package/src/execution/engine/tools/platform/notification.ts +81 -81
- package/src/execution/engine/tools/platform/pdf/index.ts +110 -110
- package/src/execution/engine/tools/platform/pdf/types.ts +77 -77
- package/src/execution/engine/tools/platform/scheduler.ts +87 -87
- package/src/execution/engine/tools/platform/storage/index.ts +370 -370
- package/src/execution/engine/tools/platform/types.ts +148 -148
- package/src/execution/engine/tools/registry.ts +700 -700
- package/src/execution/engine/tools/tool-maps.ts +786 -786
- package/src/execution/engine/tools/types.ts +233 -233
- package/src/execution/engine/workflow/__tests__/errors.test.ts +139 -139
- package/src/execution/engine/workflow/errors.ts +63 -63
- package/src/execution/engine/workflow/helpers/index.ts +11 -11
- package/src/execution/engine/workflow/helpers/server/index.ts +8 -8
- package/src/execution/engine/workflow/helpers/server/llm-call.ts +93 -93
- package/src/execution/engine/workflow/index.ts +19 -19
- package/src/execution/engine/workflow/log-truncate.ts +26 -26
- package/src/execution/engine/workflow/logging.ts +191 -191
- package/src/execution/engine/workflow/types.ts +182 -182
- package/src/execution/engine/workflow/utils.ts +280 -280
- package/src/execution/engine/workflow/workflow.ts +168 -168
- package/src/execution/index.ts +3 -3
- package/src/execution/scheduler/__tests__/api-schemas.test.ts +733 -733
- package/src/execution/scheduler/__tests__/utils.test.ts +1009 -1009
- package/src/execution/scheduler/api-schemas.ts +296 -296
- package/src/execution/scheduler/index.ts +50 -50
- package/src/execution/scheduler/schemas.ts +264 -264
- package/src/execution/scheduler/types.ts +111 -111
- package/src/execution/scheduler/utils.ts +364 -364
- package/src/forms/index.ts +7 -7
- package/src/forms/schemas.ts +69 -69
- package/src/forms/types.ts +70 -70
- package/src/index.ts +71 -60
- package/src/integrations/credentials/__tests__/schemas.test.ts +82 -82
- package/src/integrations/credentials/__tests__/utils.test.ts +144 -144
- package/src/integrations/credentials/api-schemas.ts +143 -143
- package/src/integrations/credentials/index.ts +32 -32
- package/src/integrations/credentials/schemas.ts +164 -164
- package/src/integrations/credentials/utils.ts +59 -59
- package/src/integrations/oauth/__tests__/provider-registry.test.ts +59 -59
- package/src/integrations/oauth/api-schemas.ts +92 -92
- package/src/integrations/oauth/index.ts +19 -19
- package/src/integrations/oauth/provider-registry.ts +61 -61
- package/src/integrations/oauth/server/__tests__/refresh-concurrent.test.ts +183 -183
- package/src/integrations/oauth/server/__tests__/refresh.test.ts +577 -577
- package/src/integrations/oauth/server/credentials.ts +39 -39
- package/src/integrations/oauth/server/refresh.ts +214 -214
- package/src/integrations/oauth/types.ts +34 -34
- package/src/integrations/webhook-endpoints/__tests__/api-schemas.test.ts +318 -318
- package/src/integrations/webhook-endpoints/api-schemas.ts +102 -102
- package/src/integrations/webhook-endpoints/index.ts +28 -28
- package/src/integrations/webhook-endpoints/types.ts +51 -51
- package/src/operations/activities/api-schemas.ts +79 -79
- package/src/operations/activities/index.ts +9 -9
- package/src/operations/activities/sse-events.ts +30 -30
- package/src/operations/activities/types.ts +63 -63
- package/src/operations/debug-logs/client.ts +60 -60
- package/src/operations/debug-logs/debug-logger.ts +83 -83
- package/src/operations/debug-logs/index.ts +8 -8
- package/src/operations/debug-logs/server.ts +19 -19
- package/src/operations/debug-logs/types.ts +33 -33
- package/src/operations/index.ts +50 -50
- package/src/operations/notifications/api-schemas.ts +91 -91
- package/src/operations/notifications/index.ts +3 -3
- package/src/operations/notifications/sse-events.ts +21 -21
- package/src/operations/notifications/types.ts +47 -47
- package/src/operations/observability/__tests__/openrouter-cost-flow.test.ts +297 -297
- package/src/operations/observability/__tests__/utils.test.ts +54 -54
- package/src/operations/observability/ai-usage-collector.ts +64 -64
- package/src/operations/observability/index.ts +13 -13
- package/src/operations/observability/metrics-collector.ts +49 -49
- package/src/operations/observability/schemas.ts +39 -39
- package/src/operations/observability/types.ts +463 -463
- package/src/operations/observability/utils.ts +77 -77
- package/src/operations/sessions/__tests__/manager.test.ts +821 -821
- package/src/operations/sessions/index.ts +26 -26
- package/src/operations/sessions/server/manager.ts +90 -90
- package/src/operations/sessions/server/session.ts +180 -180
- package/src/operations/sessions/types.ts +98 -98
- package/src/operations/triggers/index.ts +12 -12
- package/src/operations/triggers/webhook/definitions/instantly-account-error.ts +44 -44
- package/src/operations/triggers/webhook/definitions/instantly-auto-reply-received.ts +51 -51
- package/src/operations/triggers/webhook/definitions/instantly-campaign-completed.ts +45 -45
- package/src/operations/triggers/webhook/definitions/instantly-email-bounced.ts +49 -49
- package/src/operations/triggers/webhook/definitions/instantly-lead-unsubscribed.ts +45 -45
- package/src/operations/triggers/webhook/definitions/instantly-reply-received.ts +54 -54
- package/src/operations/triggers/webhook/index.ts +35 -35
- package/src/operations/triggers/webhook/types.ts +74 -74
- package/src/organization-model/README.md +97 -97
- package/src/organization-model/__tests__/defaults.test.ts +175 -175
- package/src/organization-model/__tests__/domains/customers.test.ts +295 -295
- package/src/organization-model/__tests__/domains/goals.test.ts +479 -479
- package/src/organization-model/__tests__/domains/identity.test.ts +279 -279
- package/src/organization-model/__tests__/domains/navigation.test.ts +212 -212
- package/src/organization-model/__tests__/domains/offerings.test.ts +419 -419
- package/src/organization-model/__tests__/domains/operations.test.ts +203 -203
- package/src/organization-model/__tests__/domains/resource-mappings.test.ts +362 -362
- package/src/organization-model/__tests__/domains/roles.test.ts +347 -347
- package/src/organization-model/__tests__/domains/statuses.test.ts +243 -243
- package/src/organization-model/__tests__/foundation.test.ts +105 -105
- package/src/organization-model/__tests__/graph.test.ts +894 -894
- package/src/organization-model/__tests__/resolve.test.ts +690 -690
- package/src/organization-model/__tests__/schema.test.ts +407 -407
- package/src/organization-model/contracts.ts +14 -14
- package/src/organization-model/defaults.ts +148 -148
- package/src/organization-model/domains/branding.ts +22 -22
- package/src/organization-model/domains/customers.ts +75 -75
- package/src/organization-model/domains/features.ts +22 -22
- package/src/organization-model/domains/goals.ts +80 -80
- package/src/organization-model/domains/identity.ts +94 -94
- package/src/organization-model/domains/navigation.ts +391 -391
- package/src/organization-model/domains/offerings.ts +66 -66
- package/src/organization-model/domains/operations.ts +85 -85
- package/src/organization-model/domains/projects.ts +48 -48
- package/src/organization-model/domains/prospecting.ts +33 -33
- package/src/organization-model/domains/roles.ts +55 -55
- package/src/organization-model/domains/sales.ts +94 -94
- package/src/organization-model/domains/shared.ts +62 -62
- package/src/organization-model/domains/statuses.ts +130 -130
- package/src/organization-model/foundation.ts +97 -97
- package/src/organization-model/graph/build.ts +399 -399
- package/src/organization-model/graph/index.ts +4 -4
- package/src/organization-model/graph/schema.ts +48 -48
- package/src/organization-model/graph/types.ts +40 -40
- package/src/organization-model/index.ts +13 -13
- package/src/organization-model/organization-graph.mdx +272 -272
- package/src/organization-model/organization-model.mdx +320 -320
- package/src/organization-model/published.ts +85 -85
- package/src/organization-model/resolve.ts +66 -66
- package/src/organization-model/schema.ts +287 -287
- package/src/organization-model/types.ts +46 -46
- package/src/platform/api/index.ts +1 -1
- package/src/platform/api/types.ts +35 -35
- package/src/platform/constants/http.ts +37 -37
- package/src/platform/constants/index.ts +5 -5
- package/src/platform/constants/limits.ts +32 -32
- package/src/platform/constants/resilience.ts +51 -51
- package/src/platform/constants/timeouts.ts +20 -20
- package/src/platform/constants/versions.ts +3 -3
- package/src/platform/registry/__tests__/resource-registry-static.test.ts +347 -347
- package/src/platform/registry/__tests__/resource-registry.integration.test.ts +1028 -1028
- package/src/platform/registry/__tests__/resource-registry.list-executable.test.ts +393 -393
- package/src/platform/registry/__tests__/resource-registry.test.ts +2005 -2005
- package/src/platform/registry/__tests__/serialization.test.ts +1127 -1127
- package/src/platform/registry/command-view.ts +180 -180
- package/src/platform/registry/domains.ts +165 -165
- package/src/platform/registry/index.ts +93 -93
- package/src/platform/registry/reserved.ts +24 -24
- package/src/platform/registry/resource-metadata.ts +59 -59
- package/src/platform/registry/resource-registry.command-queue-groups.test.ts +129 -129
- package/src/platform/registry/resource-registry.ts +876 -876
- package/src/platform/registry/serialization.ts +273 -273
- package/src/platform/registry/serialized-types.ts +231 -231
- package/src/platform/registry/stats-types.ts +66 -66
- package/src/platform/registry/types.ts +404 -404
- package/src/platform/registry/validation.ts +513 -513
- package/src/platform/resilience/__tests__/rate-limiter.test.ts +471 -471
- package/src/platform/resilience/circuit-breaker.ts +164 -164
- package/src/platform/resilience/errors.ts +68 -68
- package/src/platform/resilience/http-error-mapper.ts +129 -129
- package/src/platform/resilience/index.ts +93 -93
- package/src/platform/resilience/rate-limiter-types.ts +46 -46
- package/src/platform/resilience/rate-limiter.ts +140 -140
- package/src/platform/resilience/retry.ts +89 -89
- package/src/platform/resilience/timeout.ts +63 -63
- package/src/platform/sse/events.ts +37 -37
- package/src/platform/sse/index.ts +7 -7
- package/src/platform/utils/__tests__/validation.test.ts +1083 -1083
- package/src/platform/utils/currency.ts +96 -96
- package/src/platform/utils/debounce.ts +52 -52
- package/src/platform/utils/error.ts +41 -41
- package/src/platform/utils/hmac.test.ts +97 -97
- package/src/platform/utils/index.ts +32 -32
- package/src/platform/utils/server/betterstack-logger.ts +210 -210
- package/src/platform/utils/server/hmac.ts +44 -44
- package/src/platform/utils/server/unsubscribe.ts +111 -111
- package/src/platform/utils/token-counter.ts +96 -96
- package/src/platform/utils/validation.ts +425 -425
- package/src/projects/api-schemas.ts +268 -268
- package/src/published.ts +1 -1
- package/src/reference/_generated/contracts.md +607 -607
- package/src/reference/glossary.md +105 -105
- package/src/requests/__tests__/api-schemas.test.ts +277 -277
- package/src/requests/api-schemas.ts +83 -83
- package/src/requests/index.ts +1 -1
- package/src/scaffold-registry/__tests__/index.test.ts +17 -0
- package/src/scaffold-registry/__tests__/schema.test.ts +329 -230
- package/src/scaffold-registry/index.ts +205 -189
- package/src/scaffold-registry/schema.ts +196 -128
- package/src/server.ts +272 -272
- package/src/supabase/database.types.ts +2719 -2719
- package/src/supabase/helpers.ts +20 -20
- package/src/supabase/index.ts +52 -52
- package/src/supabase/server/client.ts +58 -58
- package/src/test-utils/README.md +38 -38
- package/src/test-utils/browser-mocks.ts +54 -54
- package/src/test-utils/fixtures/api-keys.ts +52 -52
- package/src/test-utils/fixtures/index.ts +4 -4
- package/src/test-utils/fixtures/memberships.ts +80 -80
- package/src/test-utils/fixtures/organizations.ts +69 -69
- package/src/test-utils/fixtures/users.ts +79 -79
- package/src/test-utils/index.ts +11 -11
- package/src/test-utils/mocks/index.ts +2 -2
- package/src/test-utils/mocks/supabase.ts +142 -142
- package/src/test-utils/mocks/workos.ts +108 -108
- package/src/test-utils/rls/RLSTestContext.ts +556 -556
- package/src/test-utils/rls/index.ts +1 -1
|
@@ -1,577 +1,577 @@
|
|
|
1
|
-
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
2
|
-
import { refreshOAuthTokenIfExpired } from '../refresh'
|
|
3
|
-
import type { OAuthToken } from '../../types'
|
|
4
|
-
|
|
5
|
-
describe('refreshOAuthTokenIfExpired', () => {
|
|
6
|
-
// Save original fetch
|
|
7
|
-
const originalFetch = global.fetch
|
|
8
|
-
|
|
9
|
-
beforeEach(() => {
|
|
10
|
-
// Mock environment variables
|
|
11
|
-
process.env.DROPBOX_APP_KEY = 'test-dropbox-app-key'
|
|
12
|
-
process.env.DROPBOX_APP_SECRET = 'test-dropbox-app-secret'
|
|
13
|
-
process.env.GOOGLE_OAUTH_CLIENT_ID = 'test-google-client-id'
|
|
14
|
-
process.env.GOOGLE_OAUTH_CLIENT_SECRET = 'test-google-client-secret'
|
|
15
|
-
})
|
|
16
|
-
|
|
17
|
-
afterEach(() => {
|
|
18
|
-
// Restore original fetch
|
|
19
|
-
global.fetch = originalFetch
|
|
20
|
-
vi.restoreAllMocks()
|
|
21
|
-
})
|
|
22
|
-
|
|
23
|
-
describe('token expiry check', () => {
|
|
24
|
-
it('should return token unchanged if still valid (more than 5 min remaining)', async () => {
|
|
25
|
-
const validToken: OAuthToken = {
|
|
26
|
-
provider: 'dropbox',
|
|
27
|
-
accessToken: 'valid-access-token',
|
|
28
|
-
refreshToken: 'valid-refresh-token',
|
|
29
|
-
expiresAt: new Date(Date.now() + 10 * 60 * 1000).toISOString(), // 10 min in future
|
|
30
|
-
tokenType: 'Bearer',
|
|
31
|
-
scope: 'read write'
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
const result = await refreshOAuthTokenIfExpired(validToken)
|
|
35
|
-
expect(result).toBe(validToken) // Should return same object reference
|
|
36
|
-
})
|
|
37
|
-
|
|
38
|
-
it('should refresh token if expires within 5 minutes', async () => {
|
|
39
|
-
const expiringSoonToken: OAuthToken = {
|
|
40
|
-
provider: 'dropbox',
|
|
41
|
-
accessToken: 'expiring-access-token',
|
|
42
|
-
refreshToken: 'valid-refresh-token',
|
|
43
|
-
expiresAt: new Date(Date.now() + 4 * 60 * 1000).toISOString(), // 4 min in future
|
|
44
|
-
tokenType: 'Bearer'
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
// Mock successful refresh
|
|
48
|
-
global.fetch = vi.fn().mockResolvedValue({
|
|
49
|
-
ok: true,
|
|
50
|
-
json: async () => ({
|
|
51
|
-
access_token: 'new-access-token',
|
|
52
|
-
refresh_token: 'new-refresh-token',
|
|
53
|
-
expires_in: 3600
|
|
54
|
-
})
|
|
55
|
-
})
|
|
56
|
-
|
|
57
|
-
const result = await refreshOAuthTokenIfExpired(expiringSoonToken)
|
|
58
|
-
expect(result.accessToken).toBe('new-access-token')
|
|
59
|
-
expect(global.fetch).toHaveBeenCalledTimes(1)
|
|
60
|
-
})
|
|
61
|
-
|
|
62
|
-
it('should refresh token if already expired', async () => {
|
|
63
|
-
const expiredToken: OAuthToken = {
|
|
64
|
-
provider: 'dropbox',
|
|
65
|
-
accessToken: 'expired-access-token',
|
|
66
|
-
refreshToken: 'valid-refresh-token',
|
|
67
|
-
expiresAt: new Date(Date.now() - 10 * 60 * 1000).toISOString(), // 10 min in past
|
|
68
|
-
tokenType: 'Bearer'
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
// Mock successful refresh
|
|
72
|
-
global.fetch = vi.fn().mockResolvedValue({
|
|
73
|
-
ok: true,
|
|
74
|
-
json: async () => ({
|
|
75
|
-
access_token: 'refreshed-access-token',
|
|
76
|
-
refresh_token: 'refreshed-refresh-token',
|
|
77
|
-
expires_in: 3600
|
|
78
|
-
})
|
|
79
|
-
})
|
|
80
|
-
|
|
81
|
-
const result = await refreshOAuthTokenIfExpired(expiredToken)
|
|
82
|
-
expect(result.accessToken).toBe('refreshed-access-token')
|
|
83
|
-
})
|
|
84
|
-
})
|
|
85
|
-
|
|
86
|
-
describe('validation', () => {
|
|
87
|
-
it('should throw if token missing provider field', async () => {
|
|
88
|
-
const tokenWithoutProvider = {
|
|
89
|
-
accessToken: 'test-access-token',
|
|
90
|
-
refreshToken: 'test-refresh-token',
|
|
91
|
-
expiresAt: new Date(Date.now() - 10 * 60 * 1000).toISOString(),
|
|
92
|
-
tokenType: 'Bearer'
|
|
93
|
-
} as OAuthToken
|
|
94
|
-
|
|
95
|
-
await expect(refreshOAuthTokenIfExpired(tokenWithoutProvider)).rejects.toThrow(
|
|
96
|
-
'Token missing provider field - cannot refresh'
|
|
97
|
-
)
|
|
98
|
-
})
|
|
99
|
-
|
|
100
|
-
it('should throw if token missing refresh token', async () => {
|
|
101
|
-
const tokenWithoutRefreshToken: OAuthToken = {
|
|
102
|
-
provider: 'dropbox',
|
|
103
|
-
accessToken: 'test-access-token',
|
|
104
|
-
refreshToken: '', // Empty refresh token
|
|
105
|
-
expiresAt: new Date(Date.now() - 10 * 60 * 1000).toISOString(),
|
|
106
|
-
tokenType: 'Bearer'
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
await expect(refreshOAuthTokenIfExpired(tokenWithoutRefreshToken)).rejects.toThrow(
|
|
110
|
-
'Token cannot be refreshed (no refresh_token). User must re-authorize.'
|
|
111
|
-
)
|
|
112
|
-
})
|
|
113
|
-
|
|
114
|
-
it('should throw for unknown provider', async () => {
|
|
115
|
-
const tokenWithUnknownProvider: OAuthToken = {
|
|
116
|
-
provider: 'unknown-provider',
|
|
117
|
-
accessToken: 'test-access-token',
|
|
118
|
-
refreshToken: 'test-refresh-token',
|
|
119
|
-
expiresAt: new Date(Date.now() - 10 * 60 * 1000).toISOString(),
|
|
120
|
-
tokenType: 'Bearer'
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
await expect(refreshOAuthTokenIfExpired(tokenWithUnknownProvider)).rejects.toThrow(
|
|
124
|
-
'Unknown OAuth provider: unknown-provider'
|
|
125
|
-
)
|
|
126
|
-
})
|
|
127
|
-
})
|
|
128
|
-
|
|
129
|
-
describe('exchange method: form-encoded (Dropbox)', () => {
|
|
130
|
-
it('should use form-encoded body for Dropbox', async () => {
|
|
131
|
-
const dropboxToken: OAuthToken = {
|
|
132
|
-
provider: 'dropbox',
|
|
133
|
-
accessToken: 'old-token',
|
|
134
|
-
refreshToken: 'dropbox-refresh-123',
|
|
135
|
-
expiresAt: new Date(Date.now() - 10 * 60 * 1000).toISOString(),
|
|
136
|
-
tokenType: 'Bearer'
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
const mockFetch = vi.fn().mockResolvedValue({
|
|
140
|
-
ok: true,
|
|
141
|
-
json: async () => ({
|
|
142
|
-
access_token: 'new-dropbox-token',
|
|
143
|
-
refresh_token: 'new-refresh-token',
|
|
144
|
-
expires_in: 3600
|
|
145
|
-
})
|
|
146
|
-
})
|
|
147
|
-
global.fetch = mockFetch
|
|
148
|
-
|
|
149
|
-
await refreshOAuthTokenIfExpired(dropboxToken)
|
|
150
|
-
|
|
151
|
-
expect(mockFetch).toHaveBeenCalledTimes(1)
|
|
152
|
-
const [url, options] = mockFetch.mock.calls[0]
|
|
153
|
-
|
|
154
|
-
// Verify URL
|
|
155
|
-
expect(url).toBe('https://api.dropboxapi.com/oauth2/token')
|
|
156
|
-
|
|
157
|
-
// Verify Content-Type
|
|
158
|
-
expect(options.headers['Content-Type']).toBe('application/x-www-form-urlencoded')
|
|
159
|
-
|
|
160
|
-
// Verify body is URLSearchParams format
|
|
161
|
-
const params = new URLSearchParams(options.body)
|
|
162
|
-
expect(params.get('grant_type')).toBe('refresh_token')
|
|
163
|
-
expect(params.get('refresh_token')).toBe('dropbox-refresh-123')
|
|
164
|
-
expect(params.get('client_id')).toBe('test-dropbox-app-key')
|
|
165
|
-
expect(params.get('client_secret')).toBe('test-dropbox-app-secret')
|
|
166
|
-
})
|
|
167
|
-
|
|
168
|
-
it('should handle Dropbox successful refresh', async () => {
|
|
169
|
-
const dropboxToken: OAuthToken = {
|
|
170
|
-
provider: 'dropbox',
|
|
171
|
-
accessToken: 'old-token',
|
|
172
|
-
refreshToken: 'refresh-token-123',
|
|
173
|
-
expiresAt: new Date(Date.now() - 10 * 60 * 1000).toISOString(),
|
|
174
|
-
tokenType: 'Bearer',
|
|
175
|
-
scope: 'files.content.read'
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
global.fetch = vi.fn().mockResolvedValue({
|
|
179
|
-
ok: true,
|
|
180
|
-
json: async () => ({
|
|
181
|
-
access_token: 'new-dropbox-token',
|
|
182
|
-
refresh_token: 'new-dropbox-refresh',
|
|
183
|
-
expires_in: 7200
|
|
184
|
-
})
|
|
185
|
-
})
|
|
186
|
-
|
|
187
|
-
const result = await refreshOAuthTokenIfExpired(dropboxToken)
|
|
188
|
-
|
|
189
|
-
expect(result.provider).toBe('dropbox')
|
|
190
|
-
expect(result.accessToken).toBe('new-dropbox-token')
|
|
191
|
-
expect(result.refreshToken).toBe('new-dropbox-refresh')
|
|
192
|
-
expect(result.tokenType).toBe('Bearer')
|
|
193
|
-
expect(result.scope).toBe('files.content.read') // Preserved from original
|
|
194
|
-
})
|
|
195
|
-
})
|
|
196
|
-
|
|
197
|
-
describe('exchange method: form-encoded (Google Sheets)', () => {
|
|
198
|
-
it('should use form-encoded body for Google Sheets', async () => {
|
|
199
|
-
const googleToken: OAuthToken = {
|
|
200
|
-
provider: 'google-sheets',
|
|
201
|
-
accessToken: 'old-google-token',
|
|
202
|
-
refreshToken: 'google-refresh-token',
|
|
203
|
-
expiresAt: new Date(Date.now() - 10 * 60 * 1000).toISOString(),
|
|
204
|
-
tokenType: 'Bearer'
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
const mockFetch = vi.fn().mockResolvedValue({
|
|
208
|
-
ok: true,
|
|
209
|
-
json: async () => ({
|
|
210
|
-
access_token: 'new-google-token',
|
|
211
|
-
refresh_token: 'new-google-refresh',
|
|
212
|
-
expires_in: 3600
|
|
213
|
-
})
|
|
214
|
-
})
|
|
215
|
-
global.fetch = mockFetch
|
|
216
|
-
|
|
217
|
-
await refreshOAuthTokenIfExpired(googleToken)
|
|
218
|
-
|
|
219
|
-
expect(mockFetch).toHaveBeenCalledTimes(1)
|
|
220
|
-
const [url, options] = mockFetch.mock.calls[0]
|
|
221
|
-
|
|
222
|
-
// Verify URL
|
|
223
|
-
expect(url).toBe('https://oauth2.googleapis.com/token')
|
|
224
|
-
|
|
225
|
-
// Verify Content-Type
|
|
226
|
-
expect(options.headers['Content-Type']).toBe('application/x-www-form-urlencoded')
|
|
227
|
-
|
|
228
|
-
// Verify body is URLSearchParams format
|
|
229
|
-
const params = new URLSearchParams(options.body)
|
|
230
|
-
expect(params.get('grant_type')).toBe('refresh_token')
|
|
231
|
-
expect(params.get('refresh_token')).toBe('google-refresh-token')
|
|
232
|
-
expect(params.get('client_id')).toBe('test-google-client-id')
|
|
233
|
-
expect(params.get('client_secret')).toBe('test-google-client-secret')
|
|
234
|
-
})
|
|
235
|
-
})
|
|
236
|
-
|
|
237
|
-
describe('defensive normalization', () => {
|
|
238
|
-
it('should handle RFC 6749-compliant snake_case response', async () => {
|
|
239
|
-
const token: OAuthToken = {
|
|
240
|
-
provider: 'dropbox',
|
|
241
|
-
accessToken: 'old-token',
|
|
242
|
-
refreshToken: 'refresh-token',
|
|
243
|
-
expiresAt: new Date(Date.now() - 10 * 60 * 1000).toISOString(),
|
|
244
|
-
tokenType: 'Bearer'
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
global.fetch = vi.fn().mockResolvedValue({
|
|
248
|
-
ok: true,
|
|
249
|
-
json: async () => ({
|
|
250
|
-
access_token: 'new-token-snake',
|
|
251
|
-
refresh_token: 'new-refresh-snake',
|
|
252
|
-
expires_in: 3600
|
|
253
|
-
})
|
|
254
|
-
})
|
|
255
|
-
|
|
256
|
-
const result = await refreshOAuthTokenIfExpired(token)
|
|
257
|
-
|
|
258
|
-
expect(result.accessToken).toBe('new-token-snake')
|
|
259
|
-
expect(result.refreshToken).toBe('new-refresh-snake')
|
|
260
|
-
})
|
|
261
|
-
|
|
262
|
-
it('should handle non-compliant camelCase response', async () => {
|
|
263
|
-
const token: OAuthToken = {
|
|
264
|
-
provider: 'dropbox',
|
|
265
|
-
accessToken: 'old-token',
|
|
266
|
-
refreshToken: 'refresh-token',
|
|
267
|
-
expiresAt: new Date(Date.now() - 10 * 60 * 1000).toISOString(),
|
|
268
|
-
tokenType: 'Bearer'
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
global.fetch = vi.fn().mockResolvedValue({
|
|
272
|
-
ok: true,
|
|
273
|
-
json: async () => ({
|
|
274
|
-
accessToken: 'new-token-camel',
|
|
275
|
-
refreshToken: 'new-refresh-camel',
|
|
276
|
-
expiresIn: 7200
|
|
277
|
-
})
|
|
278
|
-
})
|
|
279
|
-
|
|
280
|
-
const result = await refreshOAuthTokenIfExpired(token)
|
|
281
|
-
|
|
282
|
-
expect(result.accessToken).toBe('new-token-camel')
|
|
283
|
-
expect(result.refreshToken).toBe('new-refresh-camel')
|
|
284
|
-
})
|
|
285
|
-
|
|
286
|
-
it('should prefer snake_case over camelCase when both present', async () => {
|
|
287
|
-
const token: OAuthToken = {
|
|
288
|
-
provider: 'dropbox',
|
|
289
|
-
accessToken: 'old-token',
|
|
290
|
-
refreshToken: 'refresh-token',
|
|
291
|
-
expiresAt: new Date(Date.now() - 10 * 60 * 1000).toISOString(),
|
|
292
|
-
tokenType: 'Bearer'
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
global.fetch = vi.fn().mockResolvedValue({
|
|
296
|
-
ok: true,
|
|
297
|
-
json: async () => ({
|
|
298
|
-
access_token: 'snake-case-token',
|
|
299
|
-
accessToken: 'camel-case-token',
|
|
300
|
-
refresh_token: 'snake-case-refresh',
|
|
301
|
-
refreshToken: 'camel-case-refresh',
|
|
302
|
-
expires_in: 3600
|
|
303
|
-
})
|
|
304
|
-
})
|
|
305
|
-
|
|
306
|
-
const result = await refreshOAuthTokenIfExpired(token)
|
|
307
|
-
|
|
308
|
-
// Should prefer snake_case (RFC 6749 standard)
|
|
309
|
-
expect(result.accessToken).toBe('snake-case-token')
|
|
310
|
-
expect(result.refreshToken).toBe('snake-case-refresh')
|
|
311
|
-
})
|
|
312
|
-
|
|
313
|
-
it('should throw if access_token missing in response', async () => {
|
|
314
|
-
const token: OAuthToken = {
|
|
315
|
-
provider: 'dropbox',
|
|
316
|
-
accessToken: 'old-token',
|
|
317
|
-
refreshToken: 'refresh-token',
|
|
318
|
-
expiresAt: new Date(Date.now() - 10 * 60 * 1000).toISOString(),
|
|
319
|
-
tokenType: 'Bearer'
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
global.fetch = vi.fn().mockResolvedValue({
|
|
323
|
-
ok: true,
|
|
324
|
-
json: async () => ({
|
|
325
|
-
// Missing access_token field
|
|
326
|
-
refresh_token: 'new-refresh',
|
|
327
|
-
expires_in: 3600
|
|
328
|
-
})
|
|
329
|
-
})
|
|
330
|
-
|
|
331
|
-
await expect(refreshOAuthTokenIfExpired(token)).rejects.toThrow(
|
|
332
|
-
'Invalid token refresh response: missing access_token'
|
|
333
|
-
)
|
|
334
|
-
})
|
|
335
|
-
|
|
336
|
-
it('should use default expires_in if missing', async () => {
|
|
337
|
-
const token: OAuthToken = {
|
|
338
|
-
provider: 'dropbox',
|
|
339
|
-
accessToken: 'old-token',
|
|
340
|
-
refreshToken: 'refresh-token',
|
|
341
|
-
expiresAt: new Date(Date.now() - 10 * 60 * 1000).toISOString(),
|
|
342
|
-
tokenType: 'Bearer'
|
|
343
|
-
}
|
|
344
|
-
|
|
345
|
-
const beforeRefresh = Date.now()
|
|
346
|
-
|
|
347
|
-
global.fetch = vi.fn().mockResolvedValue({
|
|
348
|
-
ok: true,
|
|
349
|
-
json: async () => ({
|
|
350
|
-
access_token: 'new-token',
|
|
351
|
-
refresh_token: 'new-refresh'
|
|
352
|
-
// Missing expires_in - should default to 3600
|
|
353
|
-
})
|
|
354
|
-
})
|
|
355
|
-
|
|
356
|
-
const result = await refreshOAuthTokenIfExpired(token)
|
|
357
|
-
|
|
358
|
-
const afterRefresh = Date.now()
|
|
359
|
-
const expiresAt = new Date(result.expiresAt).getTime()
|
|
360
|
-
|
|
361
|
-
// Should default to 3600 seconds (1 hour)
|
|
362
|
-
const expectedExpiry = beforeRefresh + 3600 * 1000
|
|
363
|
-
expect(expiresAt).toBeGreaterThanOrEqual(expectedExpiry - 100) // Allow 100ms tolerance
|
|
364
|
-
expect(expiresAt).toBeLessThanOrEqual(afterRefresh + 3600 * 1000)
|
|
365
|
-
})
|
|
366
|
-
|
|
367
|
-
it('should reuse existing refresh_token if provider does not return new one', async () => {
|
|
368
|
-
const token: OAuthToken = {
|
|
369
|
-
provider: 'dropbox',
|
|
370
|
-
accessToken: 'old-token',
|
|
371
|
-
refreshToken: 'original-refresh-token',
|
|
372
|
-
expiresAt: new Date(Date.now() - 10 * 60 * 1000).toISOString(),
|
|
373
|
-
tokenType: 'Bearer'
|
|
374
|
-
}
|
|
375
|
-
|
|
376
|
-
global.fetch = vi.fn().mockResolvedValue({
|
|
377
|
-
ok: true,
|
|
378
|
-
json: async () => ({
|
|
379
|
-
access_token: 'new-token',
|
|
380
|
-
// No refresh_token in response - some providers don't return new one
|
|
381
|
-
expires_in: 3600
|
|
382
|
-
})
|
|
383
|
-
})
|
|
384
|
-
|
|
385
|
-
const result = await refreshOAuthTokenIfExpired(token)
|
|
386
|
-
|
|
387
|
-
expect(result.accessToken).toBe('new-token')
|
|
388
|
-
expect(result.refreshToken).toBe('original-refresh-token') // Reused
|
|
389
|
-
})
|
|
390
|
-
})
|
|
391
|
-
|
|
392
|
-
describe('error handling', () => {
|
|
393
|
-
it('should parse OAuth error response with error and error_description', async () => {
|
|
394
|
-
const token: OAuthToken = {
|
|
395
|
-
provider: 'dropbox',
|
|
396
|
-
accessToken: 'old-token',
|
|
397
|
-
refreshToken: 'invalid-refresh-token',
|
|
398
|
-
expiresAt: new Date(Date.now() - 10 * 60 * 1000).toISOString(),
|
|
399
|
-
tokenType: 'Bearer'
|
|
400
|
-
}
|
|
401
|
-
|
|
402
|
-
global.fetch = vi.fn().mockResolvedValue({
|
|
403
|
-
ok: false,
|
|
404
|
-
status: 400,
|
|
405
|
-
json: async () => ({
|
|
406
|
-
error: 'invalid_request',
|
|
407
|
-
error_description: 'The refresh token is invalid or expired'
|
|
408
|
-
})
|
|
409
|
-
})
|
|
410
|
-
|
|
411
|
-
await expect(refreshOAuthTokenIfExpired(token)).rejects.toThrow(
|
|
412
|
-
'Failed to refresh OAuth token for dropbox: 400 - invalid_request: The refresh token is invalid or expired'
|
|
413
|
-
)
|
|
414
|
-
})
|
|
415
|
-
|
|
416
|
-
it('should identify invalid_grant errors', async () => {
|
|
417
|
-
const token: OAuthToken = {
|
|
418
|
-
provider: 'google-sheets',
|
|
419
|
-
accessToken: 'old-token',
|
|
420
|
-
refreshToken: 'revoked-refresh-token',
|
|
421
|
-
expiresAt: new Date(Date.now() - 10 * 60 * 1000).toISOString(),
|
|
422
|
-
tokenType: 'Bearer'
|
|
423
|
-
}
|
|
424
|
-
|
|
425
|
-
global.fetch = vi.fn().mockResolvedValue({
|
|
426
|
-
ok: false,
|
|
427
|
-
status: 400,
|
|
428
|
-
json: async () => ({
|
|
429
|
-
error: 'invalid_grant',
|
|
430
|
-
error_description: 'The refresh token was revoked'
|
|
431
|
-
})
|
|
432
|
-
})
|
|
433
|
-
|
|
434
|
-
await expect(refreshOAuthTokenIfExpired(token)).rejects.toThrow(
|
|
435
|
-
'invalid_grant: The refresh token was revoked (User must re-authorize)'
|
|
436
|
-
)
|
|
437
|
-
})
|
|
438
|
-
|
|
439
|
-
it('should handle non-JSON error responses', async () => {
|
|
440
|
-
const token: OAuthToken = {
|
|
441
|
-
provider: 'dropbox',
|
|
442
|
-
accessToken: 'old-token',
|
|
443
|
-
refreshToken: 'refresh-token',
|
|
444
|
-
expiresAt: new Date(Date.now() - 10 * 60 * 1000).toISOString(),
|
|
445
|
-
tokenType: 'Bearer'
|
|
446
|
-
}
|
|
447
|
-
|
|
448
|
-
global.fetch = vi.fn().mockResolvedValue({
|
|
449
|
-
ok: false,
|
|
450
|
-
status: 503,
|
|
451
|
-
statusText: 'Service Unavailable',
|
|
452
|
-
json: async () => {
|
|
453
|
-
throw new Error('Not JSON')
|
|
454
|
-
}
|
|
455
|
-
})
|
|
456
|
-
|
|
457
|
-
await expect(refreshOAuthTokenIfExpired(token)).rejects.toThrow(
|
|
458
|
-
'Failed to refresh OAuth token for dropbox: 503 - Service Unavailable'
|
|
459
|
-
)
|
|
460
|
-
})
|
|
461
|
-
|
|
462
|
-
it('should handle network errors', async () => {
|
|
463
|
-
const token: OAuthToken = {
|
|
464
|
-
provider: 'dropbox',
|
|
465
|
-
accessToken: 'old-token',
|
|
466
|
-
refreshToken: 'refresh-token',
|
|
467
|
-
expiresAt: new Date(Date.now() - 10 * 60 * 1000).toISOString(),
|
|
468
|
-
tokenType: 'Bearer'
|
|
469
|
-
}
|
|
470
|
-
|
|
471
|
-
global.fetch = vi.fn().mockRejectedValue(new Error('Network error'))
|
|
472
|
-
|
|
473
|
-
await expect(refreshOAuthTokenIfExpired(token)).rejects.toThrow('Network error')
|
|
474
|
-
})
|
|
475
|
-
})
|
|
476
|
-
|
|
477
|
-
describe('token field preservation', () => {
|
|
478
|
-
it('should preserve all original token fields', async () => {
|
|
479
|
-
const token: OAuthToken = {
|
|
480
|
-
provider: 'dropbox',
|
|
481
|
-
accessToken: 'old-token',
|
|
482
|
-
refreshToken: 'refresh-token',
|
|
483
|
-
expiresAt: new Date(Date.now() - 10 * 60 * 1000).toISOString(),
|
|
484
|
-
tokenType: 'Bearer',
|
|
485
|
-
scope: 'read write',
|
|
486
|
-
// Custom fields
|
|
487
|
-
customField: 'custom-value',
|
|
488
|
-
metadata: { userId: '12345' }
|
|
489
|
-
}
|
|
490
|
-
|
|
491
|
-
global.fetch = vi.fn().mockResolvedValue({
|
|
492
|
-
ok: true,
|
|
493
|
-
json: async () => ({
|
|
494
|
-
access_token: 'new-token',
|
|
495
|
-
refresh_token: 'new-refresh',
|
|
496
|
-
expires_in: 3600
|
|
497
|
-
})
|
|
498
|
-
})
|
|
499
|
-
|
|
500
|
-
const result = await refreshOAuthTokenIfExpired(token)
|
|
501
|
-
|
|
502
|
-
// Updated fields
|
|
503
|
-
expect(result.accessToken).toBe('new-token')
|
|
504
|
-
expect(result.refreshToken).toBe('new-refresh')
|
|
505
|
-
|
|
506
|
-
// Preserved fields
|
|
507
|
-
expect(result.provider).toBe('dropbox')
|
|
508
|
-
expect(result.tokenType).toBe('Bearer')
|
|
509
|
-
expect(result.scope).toBe('read write')
|
|
510
|
-
expect(result.customField).toBe('custom-value')
|
|
511
|
-
expect(result.metadata).toEqual({ userId: '12345' })
|
|
512
|
-
})
|
|
513
|
-
|
|
514
|
-
it('should update expiresAt correctly', async () => {
|
|
515
|
-
const token: OAuthToken = {
|
|
516
|
-
provider: 'dropbox',
|
|
517
|
-
accessToken: 'old-token',
|
|
518
|
-
refreshToken: 'refresh-token',
|
|
519
|
-
expiresAt: new Date(Date.now() - 10 * 60 * 1000).toISOString(),
|
|
520
|
-
tokenType: 'Bearer'
|
|
521
|
-
}
|
|
522
|
-
|
|
523
|
-
const beforeRefresh = Date.now()
|
|
524
|
-
|
|
525
|
-
global.fetch = vi.fn().mockResolvedValue({
|
|
526
|
-
ok: true,
|
|
527
|
-
json: async () => ({
|
|
528
|
-
access_token: 'new-token',
|
|
529
|
-
refresh_token: 'new-refresh',
|
|
530
|
-
expires_in: 7200 // 2 hours
|
|
531
|
-
})
|
|
532
|
-
})
|
|
533
|
-
|
|
534
|
-
const result = await refreshOAuthTokenIfExpired(token)
|
|
535
|
-
|
|
536
|
-
const afterRefresh = Date.now()
|
|
537
|
-
const expiresAt = new Date(result.expiresAt).getTime()
|
|
538
|
-
|
|
539
|
-
// Should be approximately 2 hours from now
|
|
540
|
-
const expectedExpiry = beforeRefresh + 7200 * 1000
|
|
541
|
-
expect(expiresAt).toBeGreaterThanOrEqual(expectedExpiry - 100) // Allow 100ms tolerance
|
|
542
|
-
expect(expiresAt).toBeLessThanOrEqual(afterRefresh + 7200 * 1000)
|
|
543
|
-
})
|
|
544
|
-
})
|
|
545
|
-
|
|
546
|
-
describe('environment variable errors', () => {
|
|
547
|
-
it('should throw if CLIENT_ID missing', async () => {
|
|
548
|
-
delete process.env.DROPBOX_APP_KEY
|
|
549
|
-
|
|
550
|
-
const token: OAuthToken = {
|
|
551
|
-
provider: 'dropbox',
|
|
552
|
-
accessToken: 'old-token',
|
|
553
|
-
refreshToken: 'refresh-token',
|
|
554
|
-
expiresAt: new Date(Date.now() - 10 * 60 * 1000).toISOString(),
|
|
555
|
-
tokenType: 'Bearer'
|
|
556
|
-
}
|
|
557
|
-
|
|
558
|
-
await expect(refreshOAuthTokenIfExpired(token)).rejects.toThrow('Missing environment variable: DROPBOX_APP_KEY')
|
|
559
|
-
})
|
|
560
|
-
|
|
561
|
-
it('should throw if CLIENT_SECRET missing', async () => {
|
|
562
|
-
delete process.env.GOOGLE_OAUTH_CLIENT_SECRET
|
|
563
|
-
|
|
564
|
-
const token: OAuthToken = {
|
|
565
|
-
provider: 'google-sheets',
|
|
566
|
-
accessToken: 'old-token',
|
|
567
|
-
refreshToken: 'refresh-token',
|
|
568
|
-
expiresAt: new Date(Date.now() - 10 * 60 * 1000).toISOString(),
|
|
569
|
-
tokenType: 'Bearer'
|
|
570
|
-
}
|
|
571
|
-
|
|
572
|
-
await expect(refreshOAuthTokenIfExpired(token)).rejects.toThrow(
|
|
573
|
-
'Missing environment variable: GOOGLE_OAUTH_CLIENT_SECRET'
|
|
574
|
-
)
|
|
575
|
-
})
|
|
576
|
-
})
|
|
577
|
-
})
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
2
|
+
import { refreshOAuthTokenIfExpired } from '../refresh'
|
|
3
|
+
import type { OAuthToken } from '../../types'
|
|
4
|
+
|
|
5
|
+
describe('refreshOAuthTokenIfExpired', () => {
|
|
6
|
+
// Save original fetch
|
|
7
|
+
const originalFetch = global.fetch
|
|
8
|
+
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
// Mock environment variables
|
|
11
|
+
process.env.DROPBOX_APP_KEY = 'test-dropbox-app-key'
|
|
12
|
+
process.env.DROPBOX_APP_SECRET = 'test-dropbox-app-secret'
|
|
13
|
+
process.env.GOOGLE_OAUTH_CLIENT_ID = 'test-google-client-id'
|
|
14
|
+
process.env.GOOGLE_OAUTH_CLIENT_SECRET = 'test-google-client-secret'
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
afterEach(() => {
|
|
18
|
+
// Restore original fetch
|
|
19
|
+
global.fetch = originalFetch
|
|
20
|
+
vi.restoreAllMocks()
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
describe('token expiry check', () => {
|
|
24
|
+
it('should return token unchanged if still valid (more than 5 min remaining)', async () => {
|
|
25
|
+
const validToken: OAuthToken = {
|
|
26
|
+
provider: 'dropbox',
|
|
27
|
+
accessToken: 'valid-access-token',
|
|
28
|
+
refreshToken: 'valid-refresh-token',
|
|
29
|
+
expiresAt: new Date(Date.now() + 10 * 60 * 1000).toISOString(), // 10 min in future
|
|
30
|
+
tokenType: 'Bearer',
|
|
31
|
+
scope: 'read write'
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const result = await refreshOAuthTokenIfExpired(validToken)
|
|
35
|
+
expect(result).toBe(validToken) // Should return same object reference
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('should refresh token if expires within 5 minutes', async () => {
|
|
39
|
+
const expiringSoonToken: OAuthToken = {
|
|
40
|
+
provider: 'dropbox',
|
|
41
|
+
accessToken: 'expiring-access-token',
|
|
42
|
+
refreshToken: 'valid-refresh-token',
|
|
43
|
+
expiresAt: new Date(Date.now() + 4 * 60 * 1000).toISOString(), // 4 min in future
|
|
44
|
+
tokenType: 'Bearer'
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Mock successful refresh
|
|
48
|
+
global.fetch = vi.fn().mockResolvedValue({
|
|
49
|
+
ok: true,
|
|
50
|
+
json: async () => ({
|
|
51
|
+
access_token: 'new-access-token',
|
|
52
|
+
refresh_token: 'new-refresh-token',
|
|
53
|
+
expires_in: 3600
|
|
54
|
+
})
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
const result = await refreshOAuthTokenIfExpired(expiringSoonToken)
|
|
58
|
+
expect(result.accessToken).toBe('new-access-token')
|
|
59
|
+
expect(global.fetch).toHaveBeenCalledTimes(1)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('should refresh token if already expired', async () => {
|
|
63
|
+
const expiredToken: OAuthToken = {
|
|
64
|
+
provider: 'dropbox',
|
|
65
|
+
accessToken: 'expired-access-token',
|
|
66
|
+
refreshToken: 'valid-refresh-token',
|
|
67
|
+
expiresAt: new Date(Date.now() - 10 * 60 * 1000).toISOString(), // 10 min in past
|
|
68
|
+
tokenType: 'Bearer'
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Mock successful refresh
|
|
72
|
+
global.fetch = vi.fn().mockResolvedValue({
|
|
73
|
+
ok: true,
|
|
74
|
+
json: async () => ({
|
|
75
|
+
access_token: 'refreshed-access-token',
|
|
76
|
+
refresh_token: 'refreshed-refresh-token',
|
|
77
|
+
expires_in: 3600
|
|
78
|
+
})
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
const result = await refreshOAuthTokenIfExpired(expiredToken)
|
|
82
|
+
expect(result.accessToken).toBe('refreshed-access-token')
|
|
83
|
+
})
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
describe('validation', () => {
|
|
87
|
+
it('should throw if token missing provider field', async () => {
|
|
88
|
+
const tokenWithoutProvider = {
|
|
89
|
+
accessToken: 'test-access-token',
|
|
90
|
+
refreshToken: 'test-refresh-token',
|
|
91
|
+
expiresAt: new Date(Date.now() - 10 * 60 * 1000).toISOString(),
|
|
92
|
+
tokenType: 'Bearer'
|
|
93
|
+
} as OAuthToken
|
|
94
|
+
|
|
95
|
+
await expect(refreshOAuthTokenIfExpired(tokenWithoutProvider)).rejects.toThrow(
|
|
96
|
+
'Token missing provider field - cannot refresh'
|
|
97
|
+
)
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('should throw if token missing refresh token', async () => {
|
|
101
|
+
const tokenWithoutRefreshToken: OAuthToken = {
|
|
102
|
+
provider: 'dropbox',
|
|
103
|
+
accessToken: 'test-access-token',
|
|
104
|
+
refreshToken: '', // Empty refresh token
|
|
105
|
+
expiresAt: new Date(Date.now() - 10 * 60 * 1000).toISOString(),
|
|
106
|
+
tokenType: 'Bearer'
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
await expect(refreshOAuthTokenIfExpired(tokenWithoutRefreshToken)).rejects.toThrow(
|
|
110
|
+
'Token cannot be refreshed (no refresh_token). User must re-authorize.'
|
|
111
|
+
)
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('should throw for unknown provider', async () => {
|
|
115
|
+
const tokenWithUnknownProvider: OAuthToken = {
|
|
116
|
+
provider: 'unknown-provider',
|
|
117
|
+
accessToken: 'test-access-token',
|
|
118
|
+
refreshToken: 'test-refresh-token',
|
|
119
|
+
expiresAt: new Date(Date.now() - 10 * 60 * 1000).toISOString(),
|
|
120
|
+
tokenType: 'Bearer'
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
await expect(refreshOAuthTokenIfExpired(tokenWithUnknownProvider)).rejects.toThrow(
|
|
124
|
+
'Unknown OAuth provider: unknown-provider'
|
|
125
|
+
)
|
|
126
|
+
})
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
describe('exchange method: form-encoded (Dropbox)', () => {
|
|
130
|
+
it('should use form-encoded body for Dropbox', async () => {
|
|
131
|
+
const dropboxToken: OAuthToken = {
|
|
132
|
+
provider: 'dropbox',
|
|
133
|
+
accessToken: 'old-token',
|
|
134
|
+
refreshToken: 'dropbox-refresh-123',
|
|
135
|
+
expiresAt: new Date(Date.now() - 10 * 60 * 1000).toISOString(),
|
|
136
|
+
tokenType: 'Bearer'
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const mockFetch = vi.fn().mockResolvedValue({
|
|
140
|
+
ok: true,
|
|
141
|
+
json: async () => ({
|
|
142
|
+
access_token: 'new-dropbox-token',
|
|
143
|
+
refresh_token: 'new-refresh-token',
|
|
144
|
+
expires_in: 3600
|
|
145
|
+
})
|
|
146
|
+
})
|
|
147
|
+
global.fetch = mockFetch
|
|
148
|
+
|
|
149
|
+
await refreshOAuthTokenIfExpired(dropboxToken)
|
|
150
|
+
|
|
151
|
+
expect(mockFetch).toHaveBeenCalledTimes(1)
|
|
152
|
+
const [url, options] = mockFetch.mock.calls[0]
|
|
153
|
+
|
|
154
|
+
// Verify URL
|
|
155
|
+
expect(url).toBe('https://api.dropboxapi.com/oauth2/token')
|
|
156
|
+
|
|
157
|
+
// Verify Content-Type
|
|
158
|
+
expect(options.headers['Content-Type']).toBe('application/x-www-form-urlencoded')
|
|
159
|
+
|
|
160
|
+
// Verify body is URLSearchParams format
|
|
161
|
+
const params = new URLSearchParams(options.body)
|
|
162
|
+
expect(params.get('grant_type')).toBe('refresh_token')
|
|
163
|
+
expect(params.get('refresh_token')).toBe('dropbox-refresh-123')
|
|
164
|
+
expect(params.get('client_id')).toBe('test-dropbox-app-key')
|
|
165
|
+
expect(params.get('client_secret')).toBe('test-dropbox-app-secret')
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
it('should handle Dropbox successful refresh', async () => {
|
|
169
|
+
const dropboxToken: OAuthToken = {
|
|
170
|
+
provider: 'dropbox',
|
|
171
|
+
accessToken: 'old-token',
|
|
172
|
+
refreshToken: 'refresh-token-123',
|
|
173
|
+
expiresAt: new Date(Date.now() - 10 * 60 * 1000).toISOString(),
|
|
174
|
+
tokenType: 'Bearer',
|
|
175
|
+
scope: 'files.content.read'
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
global.fetch = vi.fn().mockResolvedValue({
|
|
179
|
+
ok: true,
|
|
180
|
+
json: async () => ({
|
|
181
|
+
access_token: 'new-dropbox-token',
|
|
182
|
+
refresh_token: 'new-dropbox-refresh',
|
|
183
|
+
expires_in: 7200
|
|
184
|
+
})
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
const result = await refreshOAuthTokenIfExpired(dropboxToken)
|
|
188
|
+
|
|
189
|
+
expect(result.provider).toBe('dropbox')
|
|
190
|
+
expect(result.accessToken).toBe('new-dropbox-token')
|
|
191
|
+
expect(result.refreshToken).toBe('new-dropbox-refresh')
|
|
192
|
+
expect(result.tokenType).toBe('Bearer')
|
|
193
|
+
expect(result.scope).toBe('files.content.read') // Preserved from original
|
|
194
|
+
})
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
describe('exchange method: form-encoded (Google Sheets)', () => {
|
|
198
|
+
it('should use form-encoded body for Google Sheets', async () => {
|
|
199
|
+
const googleToken: OAuthToken = {
|
|
200
|
+
provider: 'google-sheets',
|
|
201
|
+
accessToken: 'old-google-token',
|
|
202
|
+
refreshToken: 'google-refresh-token',
|
|
203
|
+
expiresAt: new Date(Date.now() - 10 * 60 * 1000).toISOString(),
|
|
204
|
+
tokenType: 'Bearer'
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const mockFetch = vi.fn().mockResolvedValue({
|
|
208
|
+
ok: true,
|
|
209
|
+
json: async () => ({
|
|
210
|
+
access_token: 'new-google-token',
|
|
211
|
+
refresh_token: 'new-google-refresh',
|
|
212
|
+
expires_in: 3600
|
|
213
|
+
})
|
|
214
|
+
})
|
|
215
|
+
global.fetch = mockFetch
|
|
216
|
+
|
|
217
|
+
await refreshOAuthTokenIfExpired(googleToken)
|
|
218
|
+
|
|
219
|
+
expect(mockFetch).toHaveBeenCalledTimes(1)
|
|
220
|
+
const [url, options] = mockFetch.mock.calls[0]
|
|
221
|
+
|
|
222
|
+
// Verify URL
|
|
223
|
+
expect(url).toBe('https://oauth2.googleapis.com/token')
|
|
224
|
+
|
|
225
|
+
// Verify Content-Type
|
|
226
|
+
expect(options.headers['Content-Type']).toBe('application/x-www-form-urlencoded')
|
|
227
|
+
|
|
228
|
+
// Verify body is URLSearchParams format
|
|
229
|
+
const params = new URLSearchParams(options.body)
|
|
230
|
+
expect(params.get('grant_type')).toBe('refresh_token')
|
|
231
|
+
expect(params.get('refresh_token')).toBe('google-refresh-token')
|
|
232
|
+
expect(params.get('client_id')).toBe('test-google-client-id')
|
|
233
|
+
expect(params.get('client_secret')).toBe('test-google-client-secret')
|
|
234
|
+
})
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
describe('defensive normalization', () => {
|
|
238
|
+
it('should handle RFC 6749-compliant snake_case response', async () => {
|
|
239
|
+
const token: OAuthToken = {
|
|
240
|
+
provider: 'dropbox',
|
|
241
|
+
accessToken: 'old-token',
|
|
242
|
+
refreshToken: 'refresh-token',
|
|
243
|
+
expiresAt: new Date(Date.now() - 10 * 60 * 1000).toISOString(),
|
|
244
|
+
tokenType: 'Bearer'
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
global.fetch = vi.fn().mockResolvedValue({
|
|
248
|
+
ok: true,
|
|
249
|
+
json: async () => ({
|
|
250
|
+
access_token: 'new-token-snake',
|
|
251
|
+
refresh_token: 'new-refresh-snake',
|
|
252
|
+
expires_in: 3600
|
|
253
|
+
})
|
|
254
|
+
})
|
|
255
|
+
|
|
256
|
+
const result = await refreshOAuthTokenIfExpired(token)
|
|
257
|
+
|
|
258
|
+
expect(result.accessToken).toBe('new-token-snake')
|
|
259
|
+
expect(result.refreshToken).toBe('new-refresh-snake')
|
|
260
|
+
})
|
|
261
|
+
|
|
262
|
+
it('should handle non-compliant camelCase response', async () => {
|
|
263
|
+
const token: OAuthToken = {
|
|
264
|
+
provider: 'dropbox',
|
|
265
|
+
accessToken: 'old-token',
|
|
266
|
+
refreshToken: 'refresh-token',
|
|
267
|
+
expiresAt: new Date(Date.now() - 10 * 60 * 1000).toISOString(),
|
|
268
|
+
tokenType: 'Bearer'
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
global.fetch = vi.fn().mockResolvedValue({
|
|
272
|
+
ok: true,
|
|
273
|
+
json: async () => ({
|
|
274
|
+
accessToken: 'new-token-camel',
|
|
275
|
+
refreshToken: 'new-refresh-camel',
|
|
276
|
+
expiresIn: 7200
|
|
277
|
+
})
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
const result = await refreshOAuthTokenIfExpired(token)
|
|
281
|
+
|
|
282
|
+
expect(result.accessToken).toBe('new-token-camel')
|
|
283
|
+
expect(result.refreshToken).toBe('new-refresh-camel')
|
|
284
|
+
})
|
|
285
|
+
|
|
286
|
+
it('should prefer snake_case over camelCase when both present', async () => {
|
|
287
|
+
const token: OAuthToken = {
|
|
288
|
+
provider: 'dropbox',
|
|
289
|
+
accessToken: 'old-token',
|
|
290
|
+
refreshToken: 'refresh-token',
|
|
291
|
+
expiresAt: new Date(Date.now() - 10 * 60 * 1000).toISOString(),
|
|
292
|
+
tokenType: 'Bearer'
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
global.fetch = vi.fn().mockResolvedValue({
|
|
296
|
+
ok: true,
|
|
297
|
+
json: async () => ({
|
|
298
|
+
access_token: 'snake-case-token',
|
|
299
|
+
accessToken: 'camel-case-token',
|
|
300
|
+
refresh_token: 'snake-case-refresh',
|
|
301
|
+
refreshToken: 'camel-case-refresh',
|
|
302
|
+
expires_in: 3600
|
|
303
|
+
})
|
|
304
|
+
})
|
|
305
|
+
|
|
306
|
+
const result = await refreshOAuthTokenIfExpired(token)
|
|
307
|
+
|
|
308
|
+
// Should prefer snake_case (RFC 6749 standard)
|
|
309
|
+
expect(result.accessToken).toBe('snake-case-token')
|
|
310
|
+
expect(result.refreshToken).toBe('snake-case-refresh')
|
|
311
|
+
})
|
|
312
|
+
|
|
313
|
+
it('should throw if access_token missing in response', async () => {
|
|
314
|
+
const token: OAuthToken = {
|
|
315
|
+
provider: 'dropbox',
|
|
316
|
+
accessToken: 'old-token',
|
|
317
|
+
refreshToken: 'refresh-token',
|
|
318
|
+
expiresAt: new Date(Date.now() - 10 * 60 * 1000).toISOString(),
|
|
319
|
+
tokenType: 'Bearer'
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
global.fetch = vi.fn().mockResolvedValue({
|
|
323
|
+
ok: true,
|
|
324
|
+
json: async () => ({
|
|
325
|
+
// Missing access_token field
|
|
326
|
+
refresh_token: 'new-refresh',
|
|
327
|
+
expires_in: 3600
|
|
328
|
+
})
|
|
329
|
+
})
|
|
330
|
+
|
|
331
|
+
await expect(refreshOAuthTokenIfExpired(token)).rejects.toThrow(
|
|
332
|
+
'Invalid token refresh response: missing access_token'
|
|
333
|
+
)
|
|
334
|
+
})
|
|
335
|
+
|
|
336
|
+
it('should use default expires_in if missing', async () => {
|
|
337
|
+
const token: OAuthToken = {
|
|
338
|
+
provider: 'dropbox',
|
|
339
|
+
accessToken: 'old-token',
|
|
340
|
+
refreshToken: 'refresh-token',
|
|
341
|
+
expiresAt: new Date(Date.now() - 10 * 60 * 1000).toISOString(),
|
|
342
|
+
tokenType: 'Bearer'
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const beforeRefresh = Date.now()
|
|
346
|
+
|
|
347
|
+
global.fetch = vi.fn().mockResolvedValue({
|
|
348
|
+
ok: true,
|
|
349
|
+
json: async () => ({
|
|
350
|
+
access_token: 'new-token',
|
|
351
|
+
refresh_token: 'new-refresh'
|
|
352
|
+
// Missing expires_in - should default to 3600
|
|
353
|
+
})
|
|
354
|
+
})
|
|
355
|
+
|
|
356
|
+
const result = await refreshOAuthTokenIfExpired(token)
|
|
357
|
+
|
|
358
|
+
const afterRefresh = Date.now()
|
|
359
|
+
const expiresAt = new Date(result.expiresAt).getTime()
|
|
360
|
+
|
|
361
|
+
// Should default to 3600 seconds (1 hour)
|
|
362
|
+
const expectedExpiry = beforeRefresh + 3600 * 1000
|
|
363
|
+
expect(expiresAt).toBeGreaterThanOrEqual(expectedExpiry - 100) // Allow 100ms tolerance
|
|
364
|
+
expect(expiresAt).toBeLessThanOrEqual(afterRefresh + 3600 * 1000)
|
|
365
|
+
})
|
|
366
|
+
|
|
367
|
+
it('should reuse existing refresh_token if provider does not return new one', async () => {
|
|
368
|
+
const token: OAuthToken = {
|
|
369
|
+
provider: 'dropbox',
|
|
370
|
+
accessToken: 'old-token',
|
|
371
|
+
refreshToken: 'original-refresh-token',
|
|
372
|
+
expiresAt: new Date(Date.now() - 10 * 60 * 1000).toISOString(),
|
|
373
|
+
tokenType: 'Bearer'
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
global.fetch = vi.fn().mockResolvedValue({
|
|
377
|
+
ok: true,
|
|
378
|
+
json: async () => ({
|
|
379
|
+
access_token: 'new-token',
|
|
380
|
+
// No refresh_token in response - some providers don't return new one
|
|
381
|
+
expires_in: 3600
|
|
382
|
+
})
|
|
383
|
+
})
|
|
384
|
+
|
|
385
|
+
const result = await refreshOAuthTokenIfExpired(token)
|
|
386
|
+
|
|
387
|
+
expect(result.accessToken).toBe('new-token')
|
|
388
|
+
expect(result.refreshToken).toBe('original-refresh-token') // Reused
|
|
389
|
+
})
|
|
390
|
+
})
|
|
391
|
+
|
|
392
|
+
describe('error handling', () => {
|
|
393
|
+
it('should parse OAuth error response with error and error_description', async () => {
|
|
394
|
+
const token: OAuthToken = {
|
|
395
|
+
provider: 'dropbox',
|
|
396
|
+
accessToken: 'old-token',
|
|
397
|
+
refreshToken: 'invalid-refresh-token',
|
|
398
|
+
expiresAt: new Date(Date.now() - 10 * 60 * 1000).toISOString(),
|
|
399
|
+
tokenType: 'Bearer'
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
global.fetch = vi.fn().mockResolvedValue({
|
|
403
|
+
ok: false,
|
|
404
|
+
status: 400,
|
|
405
|
+
json: async () => ({
|
|
406
|
+
error: 'invalid_request',
|
|
407
|
+
error_description: 'The refresh token is invalid or expired'
|
|
408
|
+
})
|
|
409
|
+
})
|
|
410
|
+
|
|
411
|
+
await expect(refreshOAuthTokenIfExpired(token)).rejects.toThrow(
|
|
412
|
+
'Failed to refresh OAuth token for dropbox: 400 - invalid_request: The refresh token is invalid or expired'
|
|
413
|
+
)
|
|
414
|
+
})
|
|
415
|
+
|
|
416
|
+
it('should identify invalid_grant errors', async () => {
|
|
417
|
+
const token: OAuthToken = {
|
|
418
|
+
provider: 'google-sheets',
|
|
419
|
+
accessToken: 'old-token',
|
|
420
|
+
refreshToken: 'revoked-refresh-token',
|
|
421
|
+
expiresAt: new Date(Date.now() - 10 * 60 * 1000).toISOString(),
|
|
422
|
+
tokenType: 'Bearer'
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
global.fetch = vi.fn().mockResolvedValue({
|
|
426
|
+
ok: false,
|
|
427
|
+
status: 400,
|
|
428
|
+
json: async () => ({
|
|
429
|
+
error: 'invalid_grant',
|
|
430
|
+
error_description: 'The refresh token was revoked'
|
|
431
|
+
})
|
|
432
|
+
})
|
|
433
|
+
|
|
434
|
+
await expect(refreshOAuthTokenIfExpired(token)).rejects.toThrow(
|
|
435
|
+
'invalid_grant: The refresh token was revoked (User must re-authorize)'
|
|
436
|
+
)
|
|
437
|
+
})
|
|
438
|
+
|
|
439
|
+
it('should handle non-JSON error responses', async () => {
|
|
440
|
+
const token: OAuthToken = {
|
|
441
|
+
provider: 'dropbox',
|
|
442
|
+
accessToken: 'old-token',
|
|
443
|
+
refreshToken: 'refresh-token',
|
|
444
|
+
expiresAt: new Date(Date.now() - 10 * 60 * 1000).toISOString(),
|
|
445
|
+
tokenType: 'Bearer'
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
global.fetch = vi.fn().mockResolvedValue({
|
|
449
|
+
ok: false,
|
|
450
|
+
status: 503,
|
|
451
|
+
statusText: 'Service Unavailable',
|
|
452
|
+
json: async () => {
|
|
453
|
+
throw new Error('Not JSON')
|
|
454
|
+
}
|
|
455
|
+
})
|
|
456
|
+
|
|
457
|
+
await expect(refreshOAuthTokenIfExpired(token)).rejects.toThrow(
|
|
458
|
+
'Failed to refresh OAuth token for dropbox: 503 - Service Unavailable'
|
|
459
|
+
)
|
|
460
|
+
})
|
|
461
|
+
|
|
462
|
+
it('should handle network errors', async () => {
|
|
463
|
+
const token: OAuthToken = {
|
|
464
|
+
provider: 'dropbox',
|
|
465
|
+
accessToken: 'old-token',
|
|
466
|
+
refreshToken: 'refresh-token',
|
|
467
|
+
expiresAt: new Date(Date.now() - 10 * 60 * 1000).toISOString(),
|
|
468
|
+
tokenType: 'Bearer'
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
global.fetch = vi.fn().mockRejectedValue(new Error('Network error'))
|
|
472
|
+
|
|
473
|
+
await expect(refreshOAuthTokenIfExpired(token)).rejects.toThrow('Network error')
|
|
474
|
+
})
|
|
475
|
+
})
|
|
476
|
+
|
|
477
|
+
describe('token field preservation', () => {
|
|
478
|
+
it('should preserve all original token fields', async () => {
|
|
479
|
+
const token: OAuthToken = {
|
|
480
|
+
provider: 'dropbox',
|
|
481
|
+
accessToken: 'old-token',
|
|
482
|
+
refreshToken: 'refresh-token',
|
|
483
|
+
expiresAt: new Date(Date.now() - 10 * 60 * 1000).toISOString(),
|
|
484
|
+
tokenType: 'Bearer',
|
|
485
|
+
scope: 'read write',
|
|
486
|
+
// Custom fields
|
|
487
|
+
customField: 'custom-value',
|
|
488
|
+
metadata: { userId: '12345' }
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
global.fetch = vi.fn().mockResolvedValue({
|
|
492
|
+
ok: true,
|
|
493
|
+
json: async () => ({
|
|
494
|
+
access_token: 'new-token',
|
|
495
|
+
refresh_token: 'new-refresh',
|
|
496
|
+
expires_in: 3600
|
|
497
|
+
})
|
|
498
|
+
})
|
|
499
|
+
|
|
500
|
+
const result = await refreshOAuthTokenIfExpired(token)
|
|
501
|
+
|
|
502
|
+
// Updated fields
|
|
503
|
+
expect(result.accessToken).toBe('new-token')
|
|
504
|
+
expect(result.refreshToken).toBe('new-refresh')
|
|
505
|
+
|
|
506
|
+
// Preserved fields
|
|
507
|
+
expect(result.provider).toBe('dropbox')
|
|
508
|
+
expect(result.tokenType).toBe('Bearer')
|
|
509
|
+
expect(result.scope).toBe('read write')
|
|
510
|
+
expect(result.customField).toBe('custom-value')
|
|
511
|
+
expect(result.metadata).toEqual({ userId: '12345' })
|
|
512
|
+
})
|
|
513
|
+
|
|
514
|
+
it('should update expiresAt correctly', async () => {
|
|
515
|
+
const token: OAuthToken = {
|
|
516
|
+
provider: 'dropbox',
|
|
517
|
+
accessToken: 'old-token',
|
|
518
|
+
refreshToken: 'refresh-token',
|
|
519
|
+
expiresAt: new Date(Date.now() - 10 * 60 * 1000).toISOString(),
|
|
520
|
+
tokenType: 'Bearer'
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
const beforeRefresh = Date.now()
|
|
524
|
+
|
|
525
|
+
global.fetch = vi.fn().mockResolvedValue({
|
|
526
|
+
ok: true,
|
|
527
|
+
json: async () => ({
|
|
528
|
+
access_token: 'new-token',
|
|
529
|
+
refresh_token: 'new-refresh',
|
|
530
|
+
expires_in: 7200 // 2 hours
|
|
531
|
+
})
|
|
532
|
+
})
|
|
533
|
+
|
|
534
|
+
const result = await refreshOAuthTokenIfExpired(token)
|
|
535
|
+
|
|
536
|
+
const afterRefresh = Date.now()
|
|
537
|
+
const expiresAt = new Date(result.expiresAt).getTime()
|
|
538
|
+
|
|
539
|
+
// Should be approximately 2 hours from now
|
|
540
|
+
const expectedExpiry = beforeRefresh + 7200 * 1000
|
|
541
|
+
expect(expiresAt).toBeGreaterThanOrEqual(expectedExpiry - 100) // Allow 100ms tolerance
|
|
542
|
+
expect(expiresAt).toBeLessThanOrEqual(afterRefresh + 7200 * 1000)
|
|
543
|
+
})
|
|
544
|
+
})
|
|
545
|
+
|
|
546
|
+
describe('environment variable errors', () => {
|
|
547
|
+
it('should throw if CLIENT_ID missing', async () => {
|
|
548
|
+
delete process.env.DROPBOX_APP_KEY
|
|
549
|
+
|
|
550
|
+
const token: OAuthToken = {
|
|
551
|
+
provider: 'dropbox',
|
|
552
|
+
accessToken: 'old-token',
|
|
553
|
+
refreshToken: 'refresh-token',
|
|
554
|
+
expiresAt: new Date(Date.now() - 10 * 60 * 1000).toISOString(),
|
|
555
|
+
tokenType: 'Bearer'
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
await expect(refreshOAuthTokenIfExpired(token)).rejects.toThrow('Missing environment variable: DROPBOX_APP_KEY')
|
|
559
|
+
})
|
|
560
|
+
|
|
561
|
+
it('should throw if CLIENT_SECRET missing', async () => {
|
|
562
|
+
delete process.env.GOOGLE_OAUTH_CLIENT_SECRET
|
|
563
|
+
|
|
564
|
+
const token: OAuthToken = {
|
|
565
|
+
provider: 'google-sheets',
|
|
566
|
+
accessToken: 'old-token',
|
|
567
|
+
refreshToken: 'refresh-token',
|
|
568
|
+
expiresAt: new Date(Date.now() - 10 * 60 * 1000).toISOString(),
|
|
569
|
+
tokenType: 'Bearer'
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
await expect(refreshOAuthTokenIfExpired(token)).rejects.toThrow(
|
|
573
|
+
'Missing environment variable: GOOGLE_OAUTH_CLIENT_SECRET'
|
|
574
|
+
)
|
|
575
|
+
})
|
|
576
|
+
})
|
|
577
|
+
})
|