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 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
+ }