belt-connect 0.2.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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +121 -0
- data/LICENSE +21 -0
- data/README.md +169 -0
- data/lib/belt/connect/configuration.rb +35 -0
- data/lib/belt/connect/snippet_parser.rb +129 -0
- data/lib/belt/connect/templates/config/widgets-README.md.erb +63 -0
- data/lib/belt/connect/templates/frontend/ConnectWidget.jsx.erb +116 -0
- data/lib/belt/connect/templates/frontend/widgets.js.erb +59 -0
- data/lib/belt/connect/templates/terraform/main.tf.erb +323 -0
- data/lib/belt/connect/templates/terraform/outputs.tf.erb +76 -0
- data/lib/belt/connect/templates/terraform/variables.tf.erb +100 -0
- data/lib/belt/connect/version.rb +7 -0
- data/lib/belt/connect.rb +35 -0
- data/lib/belt/generators/connect/add_widget.rb +319 -0
- data/lib/belt/generators/connect_generator.rb +322 -0
- data/lib/belt-connect.rb +3 -0
- metadata +110 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 125076cec482c05865c29378d307c53e93a7f4a7220672ee9d658e0e46681e9d
|
|
4
|
+
data.tar.gz: 2f245194a4675ac5078c7e24718f43dc32bb2e15b402c139c3d4151b96c23abb
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 57d369a7f0a7464ac1bb243e20077f7e3014529320755968e34da063ce9db7213aa1df856b925854ff7613c1a5ccb3f2cbed2a9de7de99b963ec5f5deab2b7d2
|
|
7
|
+
data.tar.gz: e78e4a39a94a0fc78f757f8b51779b767852ce90d767b830be6e3594f4ec1d1bbda15e4ad0f3a1f392d920c8eb0cd4bb90e05d7ea8390b2a92bcd044b77d480c
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to belt-connect are documented here.
|
|
4
|
+
Format follows [Keep a Changelog](https://keepachangelog.com/).
|
|
5
|
+
|
|
6
|
+
## [Unreleased]
|
|
7
|
+
|
|
8
|
+
## [0.2.0] - 2026-10-31
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- **Widget snippet convention: `config/connect/widgets/<name>.js`.** Raw
|
|
12
|
+
"Get widget code" snippets now have one home in the host app; the directory
|
|
13
|
+
is gitignored (the snippet holds the secret snippetId) with a committed
|
|
14
|
+
README. `belt generate connect` scaffolds the README and the `.gitignore`
|
|
15
|
+
entry (idempotently); `add-widget <name>` reads `<name>.js` from there when
|
|
16
|
+
no file is given, and copies a file passed from anywhere else into it.
|
|
17
|
+
- **`add-widget --env <env>`** writes the matched set (snippet id, widget URL,
|
|
18
|
+
element id) to the module's three SSM parameters from the one parsed snippet,
|
|
19
|
+
so nobody runs three `aws ssm put-parameter` commands by hand and mixes
|
|
20
|
+
widgets. `--app-name` / `--ssm-prefix` override the derived prefix. The
|
|
21
|
+
success message spells out the `terraform apply -refresh-only` step the SSM
|
|
22
|
+
data-source outputs need before `belt deploy frontend` — skipping it ships
|
|
23
|
+
the `PENDING_CONSOLE_SETUP` placeholder.
|
|
24
|
+
- **JWT detection.** `SnippetParser` flags an `amazon_connect('authenticate', …)`
|
|
25
|
+
hook (`result.jwt?`) and `add-widget` warns, since the loader does not sign
|
|
26
|
+
tokens yet and a JWT-secured widget would refuse to start a chat.
|
|
27
|
+
- **`scripts/chat-smoke-test/widget-check.mjs`** (demo app) replays the hosted
|
|
28
|
+
widget's handshake (`GET …/connectwidget/api/<widgetId>` with the page
|
|
29
|
+
`Origin` and `x-amz-snippet-id`) from the CLI, so a widget 403/400 can be
|
|
30
|
+
checked and re-checked without a browser, and tells you which gate you hit:
|
|
31
|
+
`400 Invalid Request` = origin not on the widget's Domains; `403 explicit
|
|
32
|
+
deny` = widget id unknown or snippet missing/stale. The README's runbook
|
|
33
|
+
has the same table.
|
|
34
|
+
- **`identity_management_type` + `users` module variables.** The Connect
|
|
35
|
+
instance can now be `CONNECT_MANAGED` (username + password logins) instead of
|
|
36
|
+
the SAML default, and the module will create the logins: each entry in
|
|
37
|
+
`users` gets the chat routing profile, the named built-in security profiles,
|
|
38
|
+
and a generated password stored as an SSM SecureString at
|
|
39
|
+
`/<app_name>/<env>/connect/users/<username>/password`. This is what makes a
|
|
40
|
+
demo chat actually connect — with SAML and no IdP, nobody can sign in to the
|
|
41
|
+
CCP to answer it. New outputs: `ccp_url`, `identity_management_type`,
|
|
42
|
+
`user_password_ssm_params`.
|
|
43
|
+
- Module `terraform { required_providers }` block (aws, null, random).
|
|
44
|
+
- **Multi-widget registry architecture.** `belt generate connect` now emits a
|
|
45
|
+
widget registry (`frontend/src/connect/widgets.js`) — a named map that is the
|
|
46
|
+
single source of truth for every hosted widget (id, loader URL, styles) — plus
|
|
47
|
+
one generic `ConnectWidget.jsx` loader driven by a widget name
|
|
48
|
+
(`<ConnectWidget name="support" />`, `openConnectChat('support')`). Adding a
|
|
49
|
+
second widget is pure config, no new component.
|
|
50
|
+
- `Belt::Connect::SnippetParser` — parses an Amazon Connect "Get widget code"
|
|
51
|
+
snippet into `{ id, snippet_id, widget_url, styles }`, so the base64 snippetId
|
|
52
|
+
is never retyped by hand (the drift that caused a right-instance/wrong-widget
|
|
53
|
+
403).
|
|
54
|
+
- `belt generate connect add-widget <name> <snippet-file>` — parses a pasted
|
|
55
|
+
snippet into the registry (id + URL as readable config) and prints the
|
|
56
|
+
`VITE_CONNECT_<NAME>_SNIPPET_ID` env line (the snippetId stays a secret).
|
|
57
|
+
|
|
58
|
+
### Fixed
|
|
59
|
+
- **Hosted widget handshake 403 caused by the loader itself.** `ConnectWidget.jsx`
|
|
60
|
+
mounted the widget `<script>` with `id="amazon-connect-chat-widget-<name>"`.
|
|
61
|
+
Amazon's client script locates its own element by id and uses **that id as
|
|
62
|
+
the widget id** in `GET <instance>/connectwidget/api/<id>`, so every chat
|
|
63
|
+
attempt hit `…/api/amazon-connect-chat-widget-support` and got
|
|
64
|
+
`403 "explicit deny in an identity-based policy"` regardless of the console's
|
|
65
|
+
domain/security settings. The loader now sets the element id to the
|
|
66
|
+
registry's `id` (the bootstrap arg of the console snippet) and treats a
|
|
67
|
+
missing id as "not configured". Several widgets still coexist — widget ids
|
|
68
|
+
are unique. Regenerate `frontend/src/connect/ConnectWidget.jsx` (or apply the
|
|
69
|
+
same change) in apps generated before this fix.
|
|
70
|
+
|
|
71
|
+
### Changed
|
|
72
|
+
- Frontend assets moved from `frontend/src/components/AmazonConnectChat.jsx` to a
|
|
73
|
+
dedicated `frontend/src/connect/` directory (registry + generic loader).
|
|
74
|
+
- The single secret per widget is now `VITE_CONNECT_<NAME>_SNIPPET_ID`; the
|
|
75
|
+
widget id and loader URL are committed config in the registry (previously all
|
|
76
|
+
three were smeared across `VITE_AMAZON_CONNECT_*` env vars + hardcoded styles).
|
|
77
|
+
|
|
78
|
+
- demo-app: `frontend/env.yml` now maps `VITE_CONNECT_SUPPORT_SNIPPET_ID` to the
|
|
79
|
+
`connect_chat_snippet_id` Terraform output (the registry's single secret) so
|
|
80
|
+
`belt deploy frontend <env>` keeps injecting the snippet from SSM at build time.
|
|
81
|
+
The demo-app README's 403 runbook is rewritten in terms of the registry.
|
|
82
|
+
|
|
83
|
+
### Fixed
|
|
84
|
+
- Gem templates were behind the demo module: `chat_widget_element_id` (var, SSM
|
|
85
|
+
param) and the SSM read-back data sources + `chat_*` outputs added in #1632
|
|
86
|
+
only landed in `examples/demo-app`. They're now in the templates too.
|
|
87
|
+
- `chat_snippet_id` output is marked `sensitive` and the URL/element-id outputs
|
|
88
|
+
are wrapped in `nonsensitive()`. Without this `terraform plan`/`destroy`
|
|
89
|
+
fails with "Output refers to sensitive values" the moment a root module
|
|
90
|
+
forwards them (which the demo app does for `frontend/env.yml`).
|
|
91
|
+
- demo-app: `backend.tf` in every env pointed at the bare `belt-terraform-state`
|
|
92
|
+
placeholder instead of belt's `belt-terraform-state-<account>` bucket, so a
|
|
93
|
+
fresh checkout couldn't init against the live sandbox state.
|
|
94
|
+
|
|
95
|
+
### Removed
|
|
96
|
+
- The single-purpose `AmazonConnectChat.jsx` component template (replaced by the
|
|
97
|
+
registry + generic `ConnectWidget`).
|
|
98
|
+
|
|
99
|
+
## [0.1.0] - 2026-10-28
|
|
100
|
+
|
|
101
|
+
Initial release. Extracted and generalized from Stowzilla's standalone
|
|
102
|
+
`connect-infrastructure`.
|
|
103
|
+
|
|
104
|
+
### Added
|
|
105
|
+
- `belt generate connect` / `belt destroy connect` generator
|
|
106
|
+
(`Belt::Generators::ConnectGenerator`), auto-discovered by Belt's
|
|
107
|
+
`GeneratorRegistry`.
|
|
108
|
+
- Terraform module (`infrastructure/modules/connect/`): Connect instance (SAML,
|
|
109
|
+
chat), approved origins (null_resource + local-exec), 24/7 hours of operation,
|
|
110
|
+
chat contact flow with off-hours queuing, queue, CHAT routing profile
|
|
111
|
+
(concurrency 3), SSM params for the hosted chat widget snippet/URL
|
|
112
|
+
(with `ignore_changes`), and opt-in SAML federation (IAM provider + role) for
|
|
113
|
+
Identity Center SSO.
|
|
114
|
+
- React hosted-chat widget loader (`AmazonConnectChat.jsx`) exporting
|
|
115
|
+
`openConnectChat()`; snippet/URL read from `VITE_AMAZON_CONNECT_*` env vars.
|
|
116
|
+
- `Belt::Connect.configure` — app-agnostic config for `brand_color`, `typeface`,
|
|
117
|
+
`timezone`, `app_name`, `instance_alias` (replaces hard-coded Stowzilla green,
|
|
118
|
+
`*.stowzilla.com` origins, and the `/stowzilla/` SSM prefix).
|
|
119
|
+
- `--skip-frontend` and `--force` generator flags.
|
|
120
|
+
- Demo Belt app (`examples/demo-app`) with a `/support` page (chat widget +
|
|
121
|
+
call-center panel) and a `sandbox` nested (ephemeral) environment.
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Stowzilla Inc.
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
# belt-connect
|
|
2
|
+
|
|
3
|
+
Amazon Connect (Connect Customer) for [Belt](https://belt.dev) applications — a
|
|
4
|
+
**chat widget** and a **call-center contact flow**, scaffolded into your app with
|
|
5
|
+
one command.
|
|
6
|
+
|
|
7
|
+
`belt-connect` is a Belt plugin. Like `belt-cost-monitoring`, it emits Terraform
|
|
8
|
+
your app owns and then gets out of the way — the gem never applies AWS itself.
|
|
9
|
+
It was extracted and generalized from Stowzilla's standalone
|
|
10
|
+
`connect-infrastructure`, with the Stowzilla-specific bits (brand color, approved
|
|
11
|
+
origins, SSM prefix) turned into configuration.
|
|
12
|
+
|
|
13
|
+
## What it scaffolds
|
|
14
|
+
|
|
15
|
+
`belt generate connect` writes:
|
|
16
|
+
|
|
17
|
+
```
|
|
18
|
+
infrastructure/modules/connect/
|
|
19
|
+
main.tf # Connect instance (SAML or Connect-managed, chat), approved
|
|
20
|
+
# origins, 24/7 hours of operation, chat contact flow with
|
|
21
|
+
# off-hours queuing, queue, CHAT routing profile, optional
|
|
22
|
+
# Connect-managed users (passwords in SSM SecureString), SSM
|
|
23
|
+
# params for the hosted widget, and SAML federation (IAM
|
|
24
|
+
# provider + role) for Identity Center SSO
|
|
25
|
+
variables.tf # app_name, environment, instance_alias, approved_origins,
|
|
26
|
+
# identity_management_type, users, brand/SSM knobs,
|
|
27
|
+
# saml_metadata_file (SSO is opt-in)
|
|
28
|
+
outputs.tf # instance_id/arn, admin_console_url, ccp_url, contact_flow_id,
|
|
29
|
+
# queue_id, routing_profile_id, user_password_ssm_params,
|
|
30
|
+
# chat_* widget values (from SSM), saml_* arns
|
|
31
|
+
|
|
32
|
+
frontend/src/connect/widgets.js # Widget registry — the single source of
|
|
33
|
+
# truth for every hosted widget (id, url, styles). snippetId is
|
|
34
|
+
# read from env (it's a secret).
|
|
35
|
+
frontend/src/connect/ConnectWidget.jsx # Generic React hosted-chat loader driven
|
|
36
|
+
# by a widget name; exports openConnectChat(name)
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
The chat widget is **hosted by AWS** and the browser WebSocket does the talking,
|
|
40
|
+
so there is **no runtime Lambda** to deploy.
|
|
41
|
+
|
|
42
|
+
## Install
|
|
43
|
+
|
|
44
|
+
Add the gem to your Belt app's `Gemfile`:
|
|
45
|
+
|
|
46
|
+
```ruby
|
|
47
|
+
gem 'belt-connect', '~> 0.2'
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Then:
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
bundle install
|
|
54
|
+
belt generate connect # scaffold the module + widget
|
|
55
|
+
belt generate connect --skip-frontend # Terraform only
|
|
56
|
+
belt destroy connect # remove what was generated
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Wire it up
|
|
60
|
+
|
|
61
|
+
1. Reference the module in your environment `main.tf`:
|
|
62
|
+
|
|
63
|
+
```hcl
|
|
64
|
+
module "connect" {
|
|
65
|
+
source = "../modules/connect"
|
|
66
|
+
app_name = var.app_name
|
|
67
|
+
environment = var.environment
|
|
68
|
+
aws_region = var.aws_region
|
|
69
|
+
instance_alias = "${var.app_name}-${var.environment}"
|
|
70
|
+
approved_origins = ["https://${var.environment}.example.com", "http://localhost:3000"]
|
|
71
|
+
|
|
72
|
+
# Optional: Identity Center SSO into the Connect console
|
|
73
|
+
# saml_metadata_file = "${path.module}/saml-metadata.xml"
|
|
74
|
+
}
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
**Who answers the chat?** The module defaults to SAML identity management,
|
|
78
|
+
which means nobody can sign in to the Contact Control Panel (CCP) until an
|
|
79
|
+
identity provider is federated in via `saml_metadata_file`. For demos,
|
|
80
|
+
sandboxes, and ephemeral environments, flip to Connect-managed logins and
|
|
81
|
+
let the module create them:
|
|
82
|
+
|
|
83
|
+
```hcl
|
|
84
|
+
identity_management_type = "CONNECT_MANAGED"
|
|
85
|
+
users = {
|
|
86
|
+
"admin" = { security_profiles = ["Admin"] } # creates the widget in the console
|
|
87
|
+
"agent" = { security_profiles = ["Agent"] } # signs in to the CCP, takes chats
|
|
88
|
+
}
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Each user gets the chat routing profile and a generated password stored at
|
|
92
|
+
`/<app_name>/<env>/connect/users/<username>/password` (SSM SecureString).
|
|
93
|
+
`identity_management_type` is a create-time setting: changing it replaces
|
|
94
|
+
the Connect instance.
|
|
95
|
+
|
|
96
|
+
2. Deploy: `belt deploy <env>`.
|
|
97
|
+
|
|
98
|
+
3. In the Connect admin console, create a **hosted chat widget**, copy its
|
|
99
|
+
"Get widget code" snippet into a file, then parse it into the registry:
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
belt generate connect add-widget support path/to/code.js
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
This reads the widget id, loader URL, and snippetId straight out of the
|
|
106
|
+
snippet — no hand-copying the base64 monster. The id and URL land in
|
|
107
|
+
`src/connect/widgets.js` (readable config); the snippetId is a secret, so the
|
|
108
|
+
command prints the env line to add:
|
|
109
|
+
|
|
110
|
+
```
|
|
111
|
+
VITE_CONNECT_SUPPORT_SNIPPET_ID=...
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
4. Mount the widget by name and open it from anywhere:
|
|
115
|
+
|
|
116
|
+
```jsx
|
|
117
|
+
import ConnectWidget, { openConnectChat } from './connect/ConnectWidget'
|
|
118
|
+
|
|
119
|
+
<ConnectWidget name="support" />
|
|
120
|
+
<button onClick={() => openConnectChat('support')}>Chat with us</button>
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Adding a second widget is pure config — `add-widget sales code.js`, drop a
|
|
124
|
+
`<ConnectWidget name="sales" />`, done. No new component, no env-var sprawl.
|
|
125
|
+
|
|
126
|
+
## Configuration
|
|
127
|
+
|
|
128
|
+
Brand the widget (defaults are app-agnostic, not Stowzilla green):
|
|
129
|
+
|
|
130
|
+
```ruby
|
|
131
|
+
# config/initializers/belt_connect.rb (or anywhere loaded before `belt generate`)
|
|
132
|
+
Belt::Connect.configure do |c|
|
|
133
|
+
c.brand_color = '#632d91' # header + chat bubbles
|
|
134
|
+
c.typeface = "'Inter', sans-serif"
|
|
135
|
+
end
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
| Setting | Default | Purpose |
|
|
139
|
+
| ---------------- | --------------------- | ----------------------------------------- |
|
|
140
|
+
| `brand_color` | `#2563eb` | Chat widget header + bubble color |
|
|
141
|
+
| `typeface` | `'Inter', sans-serif` | Chat widget font |
|
|
142
|
+
| `timezone` | `America/New_York` | Hours-of-operation timezone (also a TF var) |
|
|
143
|
+
| `app_name` | detected from routes | Resource name prefix + SSM path |
|
|
144
|
+
| `instance_alias` | — | Globally-unique Connect instance alias |
|
|
145
|
+
|
|
146
|
+
Terraform-side knobs (module variables, not gem config):
|
|
147
|
+
|
|
148
|
+
| Variable | Default | Purpose |
|
|
149
|
+
| -------------------------- | ------- | -------------------------------------------------------------- |
|
|
150
|
+
| `identity_management_type` | `SAML` | `SAML` (federate an IdP) or `CONNECT_MANAGED` (password logins) |
|
|
151
|
+
| `users` | `{}` | Connect-managed logins to create; passwords land in SSM |
|
|
152
|
+
| `saml_metadata_file` | `""` | IdP metadata XML; enables the IAM SAML provider + role |
|
|
153
|
+
|
|
154
|
+
## Demo app
|
|
155
|
+
|
|
156
|
+
A full, working Belt app that uses this plugin lives in
|
|
157
|
+
[`examples/demo-app`](examples/demo-app) — a React frontend with a `/support`
|
|
158
|
+
page wiring the chat widget + a call-center panel, plus a **sandbox** nested
|
|
159
|
+
environment. See its README to run and deploy it.
|
|
160
|
+
|
|
161
|
+
## Chat-first (today)
|
|
162
|
+
|
|
163
|
+
Mirrors Stowzilla's live rig: `inbound_calls_enabled = true`,
|
|
164
|
+
`outbound_calls_enabled = false`, CHAT-only routing (concurrency 3). Voice/cases
|
|
165
|
+
can follow by extending the routing profile + contact flow.
|
|
166
|
+
|
|
167
|
+
## License
|
|
168
|
+
|
|
169
|
+
MIT — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Belt
|
|
4
|
+
module Connect
|
|
5
|
+
# Configuration for the belt-connect plugin. These values are consumed by
|
|
6
|
+
# the generator when rendering the Terraform module and the frontend widget
|
|
7
|
+
# loader. Everything here used to be hard-coded to Stowzilla (green brand,
|
|
8
|
+
# *.stowzilla.com origins, /stowzilla/ SSM prefix) — now it is all knobs so
|
|
9
|
+
# the plugin stays app-agnostic.
|
|
10
|
+
class Configuration
|
|
11
|
+
# App name prefix used for resource names and the SSM parameter path.
|
|
12
|
+
attr_accessor :app_name
|
|
13
|
+
|
|
14
|
+
# Alias for the Connect instance (globally unique across AWS Connect).
|
|
15
|
+
attr_accessor :instance_alias
|
|
16
|
+
|
|
17
|
+
# Timezone for the 24/7 hours-of-operation resource.
|
|
18
|
+
attr_accessor :timezone
|
|
19
|
+
|
|
20
|
+
# Brand color for the hosted chat widget (header + bubbles).
|
|
21
|
+
attr_accessor :brand_color
|
|
22
|
+
|
|
23
|
+
# Typeface for the chat widget.
|
|
24
|
+
attr_accessor :typeface
|
|
25
|
+
|
|
26
|
+
def initialize
|
|
27
|
+
@app_name = nil
|
|
28
|
+
@instance_alias = nil
|
|
29
|
+
@timezone = 'America/New_York'
|
|
30
|
+
@brand_color = '#2563eb'
|
|
31
|
+
@typeface = "'Inter', sans-serif"
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Belt
|
|
4
|
+
module Connect
|
|
5
|
+
# Parses the self-contained `<script>` snippet that the Amazon Connect admin
|
|
6
|
+
# console hands you when you create a hosted chat widget ("Get widget code").
|
|
7
|
+
#
|
|
8
|
+
# The snippet is the single source of truth for a widget's identity. Rather
|
|
9
|
+
# than hand-copying the base64 snippetId and the widget URL into env vars —
|
|
10
|
+
# which is exactly the drift that caused a right-instance/wrong-widget 403 —
|
|
11
|
+
# we read them straight out of the snippet mechanically.
|
|
12
|
+
#
|
|
13
|
+
# A snippet looks like:
|
|
14
|
+
#
|
|
15
|
+
# <script type="text/javascript">
|
|
16
|
+
# (function(w, d, x, id){
|
|
17
|
+
# s=d.createElement('script');
|
|
18
|
+
# s.src='https://<instance>.my.connect.aws/connectwidget/static/amazon-connect-chat-interface-client.js';
|
|
19
|
+
# ...
|
|
20
|
+
# })(window, document, 'amazon_connect', '9da2678d-3ef8-41e5-ae28-bd380558574f');
|
|
21
|
+
# amazon_connect('styles', { iconType: 'CHAT', openChat: { ... }, closeChat: { ... } });
|
|
22
|
+
# amazon_connect('snippetId', 'QVFJREFIaDR...=');
|
|
23
|
+
# amazon_connect('supportedMessagingContentTypes', [ ... ]);
|
|
24
|
+
# </script>
|
|
25
|
+
#
|
|
26
|
+
# Usage:
|
|
27
|
+
# result = Belt::Connect::SnippetParser.parse(File.read('code.js'))
|
|
28
|
+
# result.id # => "9da2678d-3ef8-41e5-ae28-bd380558574f"
|
|
29
|
+
# result.snippet_id # => "QVFJREFIaDR...="
|
|
30
|
+
# result.widget_url # => "https://<instance>.my.connect.aws/.../amazon-connect-chat-interface-client.js"
|
|
31
|
+
# result.styles # => { "iconType" => "CHAT", "openChat" => { ... }, ... } (best-effort)
|
|
32
|
+
# result.jwt? # => true when the console emitted an authenticate hook
|
|
33
|
+
# result.to_h # => { id:, snippet_id:, widget_url:, styles:, jwt: }
|
|
34
|
+
class SnippetParser
|
|
35
|
+
Result = Struct.new(:id, :snippet_id, :widget_url, :styles, :jwt, keyword_init: true) do
|
|
36
|
+
# Did the admin pick "Yes" on "Add security for new communication widget
|
|
37
|
+
# requests"? That makes the console emit an
|
|
38
|
+
# `amazon_connect('authenticate', function(callback) { ... })` stub, and
|
|
39
|
+
# the widget refuses to start a chat until that callback hands it a JWT
|
|
40
|
+
# signed with the widget's security key. belt-connect's loader does not
|
|
41
|
+
# implement that hook yet, so a JWT snippet is something to warn about.
|
|
42
|
+
def jwt?
|
|
43
|
+
!!jwt
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def to_h
|
|
47
|
+
{ id: id, snippet_id: snippet_id, widget_url: widget_url, styles: styles, jwt: jwt? }
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
class ParseError < Belt::Connect::Error; end
|
|
52
|
+
|
|
53
|
+
# The 4th positional arg of the bootstrap IIFE is the widget instance id,
|
|
54
|
+
# e.g. ...)(window, document, 'amazon_connect', '<UUID-ish-id>');
|
|
55
|
+
ID_PATTERN = /\(\s*window\s*,\s*document\s*,\s*['"][^'"]+['"]\s*,\s*['"]([^'"]+)['"]\s*\)/.freeze
|
|
56
|
+
|
|
57
|
+
# amazon_connect('snippetId', '<base64>')
|
|
58
|
+
SNIPPET_ID_PATTERN = /amazon_connect\(\s*['"]snippetId['"]\s*,\s*['"]([^'"]+)['"]\s*\)/.freeze
|
|
59
|
+
|
|
60
|
+
# s.src='https://.../amazon-connect-chat-interface-client.js'
|
|
61
|
+
WIDGET_URL_PATTERN = /\.src\s*=\s*['"]([^'"]+)['"]/.freeze
|
|
62
|
+
|
|
63
|
+
# amazon_connect('styles', { ... }) — captures the object literal body.
|
|
64
|
+
STYLES_PATTERN = /amazon_connect\(\s*['"]styles['"]\s*,\s*(\{.*?\})\s*\)\s*;/m.freeze
|
|
65
|
+
|
|
66
|
+
# amazon_connect('authenticate', function(callback) { ... }) — only present
|
|
67
|
+
# when JWT security was enabled for the widget in the console.
|
|
68
|
+
AUTHENTICATE_PATTERN = /amazon_connect\(\s*['"]authenticate['"]\s*,/.freeze
|
|
69
|
+
|
|
70
|
+
def self.parse(snippet)
|
|
71
|
+
new(snippet).parse
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def initialize(snippet)
|
|
75
|
+
@snippet = snippet.to_s
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def parse
|
|
79
|
+
id = extract(ID_PATTERN, 'widget id (4th arg of the bootstrap IIFE)')
|
|
80
|
+
snippet_id = extract(SNIPPET_ID_PATTERN, "amazon_connect('snippetId', '...')")
|
|
81
|
+
widget_url = extract(WIDGET_URL_PATTERN, "the widget script src ('...amazon-connect-chat-interface-client.js')")
|
|
82
|
+
|
|
83
|
+
Result.new(
|
|
84
|
+
id: id,
|
|
85
|
+
snippet_id: snippet_id,
|
|
86
|
+
widget_url: widget_url,
|
|
87
|
+
styles: extract_styles,
|
|
88
|
+
jwt: @snippet.match?(AUTHENTICATE_PATTERN)
|
|
89
|
+
)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
private
|
|
93
|
+
|
|
94
|
+
def extract(pattern, label)
|
|
95
|
+
match = @snippet.match(pattern)
|
|
96
|
+
raise ParseError, "Could not find #{label} in the Connect snippet" unless match
|
|
97
|
+
|
|
98
|
+
match[1].strip
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Styles are best-effort: the console emits a JS object literal (unquoted
|
|
102
|
+
# keys, trailing commas allowed), not JSON. We do a light normalization so
|
|
103
|
+
# a human never has to retype the brand colors, but a parse failure here is
|
|
104
|
+
# non-fatal — id/snippetId/widgetUrl are what actually wire the widget up.
|
|
105
|
+
def extract_styles
|
|
106
|
+
match = @snippet.match(STYLES_PATTERN)
|
|
107
|
+
return nil unless match
|
|
108
|
+
|
|
109
|
+
normalize_js_object(match[1])
|
|
110
|
+
rescue StandardError
|
|
111
|
+
nil
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def normalize_js_object(js)
|
|
115
|
+
require 'json'
|
|
116
|
+
|
|
117
|
+
normalized = js.dup
|
|
118
|
+
# Quote bare object keys: iconType: -> "iconType":
|
|
119
|
+
normalized = normalized.gsub(/([{,]\s*)([A-Za-z_]\w*)\s*:/, '\1"\2":')
|
|
120
|
+
# Single- to double-quoted string values.
|
|
121
|
+
normalized = normalized.gsub(/'([^']*)'/, '"\1"')
|
|
122
|
+
# Drop trailing commas before } or ].
|
|
123
|
+
normalized = normalized.gsub(/,\s*([}\]])/, '\1')
|
|
124
|
+
|
|
125
|
+
JSON.parse(normalized)
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
<%# belt-connect :: config/connect/widgets/README.md -%>
|
|
2
|
+
# Amazon Connect widget sources
|
|
3
|
+
|
|
4
|
+
One file per hosted chat widget, named after its registry key:
|
|
5
|
+
|
|
6
|
+
```
|
|
7
|
+
config/connect/widgets/
|
|
8
|
+
├── README.md # this file (committed)
|
|
9
|
+
└── support.js # raw "Get widget code" snippet (gitignored)
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
Each `<name>.js` is the untouched `<script>` block the Connect admin console
|
|
13
|
+
hands you under **Channels → Communications widgets → Copy script**. It holds
|
|
14
|
+
the base64 `snippetId`, which is a secret, so `*.js` in this directory is
|
|
15
|
+
gitignored. The committed source of truth for what the app *knows* about a
|
|
16
|
+
widget is `frontend/src/connect/widgets.js`; the secret itself lives in SSM.
|
|
17
|
+
|
|
18
|
+
## Registering a widget
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
# Save the console snippet here, then:
|
|
22
|
+
belt generate connect add-widget support --env sandbox
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
That parses `config/connect/widgets/support.js`, upserts the `support` entry in
|
|
26
|
+
`frontend/src/connect/widgets.js` (widget id + loader URL — readable config,
|
|
27
|
+
commit it), and writes the matched set to the module's SSM parameters under
|
|
28
|
+
`/<app>/sandbox/connect/`.
|
|
29
|
+
|
|
30
|
+
The module exposes those parameters as Terraform outputs via SSM *data
|
|
31
|
+
sources*, which only re-read on apply. Refresh state, then rebuild so
|
|
32
|
+
`frontend/env.yml` injects the new snippet from the output:
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
terraform -chdir=infrastructure/sandbox apply -refresh-only -auto-approve
|
|
36
|
+
belt deploy frontend sandbox
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Skipping the refresh is the one way to ship a build whose widget still says
|
|
40
|
+
"pending" after the snippet is in SSM.
|
|
41
|
+
|
|
42
|
+
Passing a file from somewhere else copies it here first:
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
belt generate connect add-widget support ~/Downloads/code.js --env sandbox
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Security (JWT)
|
|
49
|
+
|
|
50
|
+
When creating the widget, answer **No - I do not want to enable JWT security
|
|
51
|
+
measures** unless the app implements the `authenticate` callback. Snippets with
|
|
52
|
+
JWT enabled contain `amazon_connect('authenticate', ...)`; `add-widget` warns
|
|
53
|
+
when it sees one, because the belt-connect loader does not sign tokens yet and
|
|
54
|
+
the widget would refuse to start a chat.
|
|
55
|
+
|
|
56
|
+
## More than one widget
|
|
57
|
+
|
|
58
|
+
The registry and `ConnectWidget` loader take any number of named widgets, one
|
|
59
|
+
mounted per page. The Terraform module provisions one matched set of SSM
|
|
60
|
+
params per environment, which `frontend/env.yml` maps to
|
|
61
|
+
`VITE_CONNECT_SUPPORT_SNIPPET_ID`. A second widget needs its own
|
|
62
|
+
`VITE_CONNECT_<NAME>_SNIPPET_ID` line in `env.yml` backed by a parameter and
|
|
63
|
+
output you add, or a value in the local `.env`.
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
<%# belt-connect :: frontend/src/connect/ConnectWidget.jsx -%>
|
|
2
|
+
// Generic Amazon Connect hosted-chat widget loader.
|
|
3
|
+
// Generated by belt-connect. One component, driven by a widget name that it
|
|
4
|
+
// looks up in the registry (./widgets.js). Mount as many as you have widgets:
|
|
5
|
+
//
|
|
6
|
+
// import ConnectWidget, { openConnectChat } from './connect/ConnectWidget'
|
|
7
|
+
//
|
|
8
|
+
// <ConnectWidget name="support" />
|
|
9
|
+
// <button onClick={() => openConnectChat('support')}>Chat with us</button>
|
|
10
|
+
//
|
|
11
|
+
import { useEffect } from "react";
|
|
12
|
+
import { getWidget, widgetDefaults } from "./widgets";
|
|
13
|
+
|
|
14
|
+
// The <script> element's id MUST be the widget id (the 4th arg of the console
|
|
15
|
+
// snippet's bootstrap IIFE). Amazon's client script looks itself up with
|
|
16
|
+
// `script[id="<widgetId>"]` to find its origin, then calls
|
|
17
|
+
// `GET <origin>/connectwidget/api/<that id>` as the chat handshake. Any other
|
|
18
|
+
// id turns that handshake into a 403 "explicit deny in an identity-based
|
|
19
|
+
// policy". Widget ids are unique, so several widgets still coexist on a page.
|
|
20
|
+
|
|
21
|
+
// Is a widget fully configured (id + snippet + url present)? Lets callers render
|
|
22
|
+
// conditional UI without duplicating the env-var checks.
|
|
23
|
+
// The Terraform module seeds the SSM params with this placeholder until a
|
|
24
|
+
// widget exists in the console; belt injects it verbatim, so treat it as unset.
|
|
25
|
+
const PLACEHOLDER = /^PENDING/;
|
|
26
|
+
|
|
27
|
+
function isUsable(value) {
|
|
28
|
+
return !!value && !PLACEHOLDER.test(value);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function isWidgetConfigured(name) {
|
|
32
|
+
try {
|
|
33
|
+
const w = getWidget(name);
|
|
34
|
+
return isUsable(w.id) && isUsable(w.snippetId) && isUsable(w.widgetUrl);
|
|
35
|
+
} catch {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Programmatically open a widget's chat panel (e.g. from a "Chat with us" button).
|
|
41
|
+
export function openConnectChat(name = "support") {
|
|
42
|
+
const btn = document.getElementById("amazon-connect-open-widget-button");
|
|
43
|
+
if (btn) {
|
|
44
|
+
btn.click();
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
// Fallback: if the widget hasn't rendered yet, queue the open command.
|
|
48
|
+
if (window.amazon_connect) {
|
|
49
|
+
window.amazon_connect("open");
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export default function ConnectWidget({ name = "support" }) {
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
const widget = getWidget(name);
|
|
56
|
+
const { id, snippetId, widgetUrl, styles = {} } = widget;
|
|
57
|
+
if (!isUsable(id) || !isUsable(snippetId) || !isUsable(widgetUrl)) return;
|
|
58
|
+
|
|
59
|
+
const elementId = id;
|
|
60
|
+
if (document.getElementById(elementId)) return;
|
|
61
|
+
|
|
62
|
+
const brandColor = styles.brandColor || widgetDefaults.brandColor;
|
|
63
|
+
const typeface = styles.typeface || widgetDefaults.typeface;
|
|
64
|
+
const iconType = styles.iconType || "CHAT";
|
|
65
|
+
|
|
66
|
+
window.amazon_connect =
|
|
67
|
+
window.amazon_connect ||
|
|
68
|
+
function () {
|
|
69
|
+
(window.amazon_connect.ac = window.amazon_connect.ac || []).push(
|
|
70
|
+
arguments,
|
|
71
|
+
);
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
amazon_connect("styles", {
|
|
75
|
+
iconType,
|
|
76
|
+
openChat: { color: "#ffffff", backgroundColor: brandColor },
|
|
77
|
+
closeChat: { color: "#ffffff", backgroundColor: brandColor },
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
amazon_connect("snippetId", snippetId);
|
|
81
|
+
|
|
82
|
+
amazon_connect("supportedMessagingContentTypes", [
|
|
83
|
+
"text/plain",
|
|
84
|
+
"text/markdown",
|
|
85
|
+
"application/vnd.amazonaws.connect.message.interactive",
|
|
86
|
+
"application/vnd.amazonaws.connect.message.interactive.response",
|
|
87
|
+
]);
|
|
88
|
+
|
|
89
|
+
amazon_connect("customStyles", {
|
|
90
|
+
global: { typeface },
|
|
91
|
+
header: {
|
|
92
|
+
headerTextColor: "#ffffff",
|
|
93
|
+
headerBackgroundColor: brandColor,
|
|
94
|
+
},
|
|
95
|
+
transcript: {
|
|
96
|
+
customerChatBubbleColor: brandColor,
|
|
97
|
+
customerMessageTextColor: "#ffffff",
|
|
98
|
+
},
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
const script = document.createElement("script");
|
|
102
|
+
script.id = elementId;
|
|
103
|
+
script.src = widgetUrl;
|
|
104
|
+
script.async = true;
|
|
105
|
+
// Registry name, for debugging which entry mounted this element.
|
|
106
|
+
script.dataset.connectWidgetName = name;
|
|
107
|
+
document.head.appendChild(script);
|
|
108
|
+
|
|
109
|
+
return () => {
|
|
110
|
+
const el = document.getElementById(elementId);
|
|
111
|
+
if (el) el.remove();
|
|
112
|
+
};
|
|
113
|
+
}, [name]);
|
|
114
|
+
|
|
115
|
+
return null;
|
|
116
|
+
}
|