@alliance-droid/status-feedback-system 4.1.6 → 4.2.1
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/CHANGELOG.md +22 -5
- package/README.md +113 -74
- package/dist/api.d.ts +3 -3
- package/dist/api.js +5 -2
- package/dist/components/DevFeedback.svelte +7 -6
- package/dist/components/DevFeedback.svelte.d.ts +2 -1
- package/dist/components/FeedbackButton.svelte +8 -7
- package/dist/components/FeedbackButton.svelte.d.ts +2 -1
- package/dist/components/FeedbackForm.svelte +8 -7
- package/dist/components/FeedbackForm.svelte.d.ts +2 -1
- package/dist/components/StatusBanner.svelte +21 -20
- package/dist/components/StatusBanner.svelte.d.ts +1 -1
- package/dist/components/StatusPage.svelte +16 -15
- package/dist/components/StatusPage.svelte.d.ts +1 -1
- package/dist/components/board/state.svelte.d.ts +2 -1
- package/dist/components/board/state.svelte.js +1 -1
- package/dist/sveltekit/server.d.ts +85 -0
- package/dist/sveltekit/server.js +383 -0
- package/package.json +69 -66
package/CHANGELOG.md
CHANGED
|
@@ -1,8 +1,25 @@
|
|
|
1
|
-
# Changelog
|
|
2
|
-
|
|
3
|
-
## 4.1
|
|
4
|
-
|
|
5
|
-
### Fixed
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 4.2.1
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
|
|
7
|
+
- SvelteKit feedback proxy: private-app authentication/authorization now runs before missing proxy configuration is reported, so unauthenticated callers receive `401` instead of learning whether `SF_API_URL` / `SF_API_KEY` are configured.
|
|
8
|
+
|
|
9
|
+
## 4.2.0
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- Added `@alliance-droid/status-feedback-system/sveltekit/server`, a server-only SvelteKit proxy adapter that lets consuming apps keep the project API key in private server environment variables. The adapter maps the package's existing `/api/...` client paths to `status-feedback-admin`, injects the private `x-api-key`, strips browser `key` query params, validates request bodies, forwards bearer tokens for privileged status endpoints, and can derive feedback `email` / response `author` from the host app's authenticated user.
|
|
14
|
+
|
|
15
|
+
### Changed
|
|
16
|
+
|
|
17
|
+
- `apiKey` is now optional for `createStatusClient`, `StatusPage`, `StatusBanner`, `FeedbackForm`, `FeedbackButton`, `DevFeedback`, and `createFeedbackBoard`. When omitted, the client no longer appends `?key=...`, which enables same-origin proxy mode with `apiUrl="/svc/feedback"`.
|
|
18
|
+
- Updated README and integration docs to make the SvelteKit proxy the recommended hardened default. Direct admin calls with an embedded project key remain supported for existing consumers.
|
|
19
|
+
|
|
20
|
+
## 4.1.6
|
|
21
|
+
|
|
22
|
+
### Fixed
|
|
6
23
|
|
|
7
24
|
- Board status badges: for privileged viewers (Admin / ProductOwner), **every** item now shows its real internal status (e.g. "QA / UAT"), not just the first one. Previously the list badge derived its label solely from the per-item transitions cache, which is only populated lazily (on menu open) plus a one-time privilege probe of `items[0]` — so exactly the first item showed its real status and the rest stayed on the coarse public projection ("In progress"). The badge now derives the privileged label from each item's own `status` (already present on every board item) via a new `internalStatusLabel`, so all rows resolve consistently once privilege is confirmed. Non-privileged viewers are unchanged (they still see the public status bucket).
|
|
8
25
|
|
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Drop-in system status pages and user feedback components for SvelteKit apps.
|
|
4
4
|
|
|
5
|
-
This
|
|
5
|
+
This package provides UI components plus an optional SvelteKit server proxy for talking to a [status-feedback-admin](https://alliance.ghe.com/alliance/status-feedback-admin) backend. The admin app handles data storage, API key management, and the admin dashboard.
|
|
6
6
|
|
|
7
7
|
## Architecture
|
|
8
8
|
|
|
@@ -38,35 +38,66 @@ AZURE_FEEDBACK_BLOB_CONNECTION_STRING=DefaultEndpointsProtocol=https;AccountName
|
|
|
38
38
|
AZURE_FEEDBACK_BLOB_CONTAINER=screenshots
|
|
39
39
|
```
|
|
40
40
|
|
|
41
|
-
### 2. Create a project and
|
|
42
|
-
|
|
43
|
-
Log into the admin dashboard, create a project, and copy its API key (prefixed `sf_`).
|
|
44
|
-
|
|
45
|
-
### 3. Install the client package
|
|
46
|
-
|
|
47
|
-
```bash
|
|
48
|
-
npm install @alliance-droid/status-feedback-system
|
|
49
|
-
```
|
|
50
|
-
|
|
51
|
-
**Peer dependencies:** `svelte ^5.
|
|
41
|
+
### 2. Create a project and keep the API key server-side
|
|
42
|
+
|
|
43
|
+
Log into the admin dashboard, create a project, and copy its API key (prefixed `sf_`). Store it in a private server-side environment variable, not a `PUBLIC_` / browser-delivered variable.
|
|
44
|
+
|
|
45
|
+
### 3. Install the client package
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
npm install @alliance-droid/status-feedback-system
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
**Peer dependencies:** `svelte ^5.48.0`, `@alliance-droid/svelte-component-library >=1.3.2`
|
|
52
|
+
|
|
53
|
+
> `@alliance-droid/svelte-component-library` must be `>=1.3.2` — earlier versions are incompatible with Svelte `5.56+` and throw `target.exclude.has is not a function` when board components render.
|
|
54
|
+
|
|
55
|
+
### 4. Add the recommended SvelteKit proxy
|
|
56
|
+
|
|
57
|
+
Use one catch-all route in the consuming app. The package owns the admin API path mapping, request validation, key injection, and `key` query stripping; the app owns auth and app-specific policy.
|
|
58
|
+
|
|
59
|
+
```ts
|
|
60
|
+
// src/routes/svc/feedback/[...path]/+server.ts
|
|
61
|
+
import { env } from '$env/dynamic/private';
|
|
62
|
+
import { createSvelteKitFeedbackProxy } from '@alliance-droid/status-feedback-system/sveltekit/server';
|
|
63
|
+
|
|
64
|
+
export const { GET, POST, PATCH } = createSvelteKitFeedbackProxy({
|
|
65
|
+
apiUrl: env.SF_API_URL,
|
|
66
|
+
apiKey: env.SF_API_KEY,
|
|
67
|
+
resolveUser: async (event) => {
|
|
68
|
+
const user = event.locals.user;
|
|
69
|
+
return user ? { email: user.email, roles: user.roles } : null;
|
|
70
|
+
},
|
|
71
|
+
requireUser: true,
|
|
72
|
+
canSubmitInAppFeedback: ({ user }) =>
|
|
73
|
+
user.roles.includes('QA') || user.roles.includes('ProductOwner'),
|
|
74
|
+
});
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Then point components at the same-origin route and omit `apiKey`:
|
|
78
|
+
|
|
79
|
+
```svelte
|
|
80
|
+
<DevFeedback apiUrl="/svc/feedback/dev" enabled={canSubmitFeedback} userEmail={user.email} />
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Direct admin calls with `apiKey="sf_..."` remain supported for existing integrations, but the proxy is the hardened default.
|
|
52
84
|
|
|
53
85
|
## Components
|
|
54
86
|
|
|
55
87
|
### StatusPage
|
|
56
88
|
|
|
57
|
-
Full status page with systems, incidents, and uptime history bars. Supports **self-fetching** (provide `apiUrl
|
|
89
|
+
Full status page with systems, incidents, and uptime history bars. Supports **self-fetching** (provide `apiUrl`; add `apiKey` only for direct admin calls) or **pass-through** (provide data directly).
|
|
58
90
|
|
|
59
91
|
```svelte
|
|
60
92
|
<script>
|
|
61
93
|
import { StatusPage } from '@alliance-droid/status-feedback-system';
|
|
62
94
|
</script>
|
|
63
95
|
|
|
64
|
-
<!-- Self-fetching
|
|
65
|
-
<StatusPage
|
|
66
|
-
projectName="My App"
|
|
67
|
-
apiUrl="
|
|
68
|
-
|
|
69
|
-
/>
|
|
96
|
+
<!-- Self-fetching through the same-origin proxy -->
|
|
97
|
+
<StatusPage
|
|
98
|
+
projectName="My App"
|
|
99
|
+
apiUrl="/svc/feedback"
|
|
100
|
+
/>
|
|
70
101
|
|
|
71
102
|
<!-- Pass-through mode -->
|
|
72
103
|
<StatusPage
|
|
@@ -81,8 +112,8 @@ Full status page with systems, incidents, and uptime history bars. Supports **se
|
|
|
81
112
|
| Prop | Type | Default | Description |
|
|
82
113
|
|------|------|---------|-------------|
|
|
83
114
|
| `projectName` | `string` | required | Display name in the header |
|
|
84
|
-
| `apiUrl` | `string` | — |
|
|
85
|
-
| `apiKey` | `string` | — | Project API key
|
|
115
|
+
| `apiUrl` | `string` | — | Proxy URL or direct admin backend URL (self-fetching mode) |
|
|
116
|
+
| `apiKey` | `string` | — | Project API key; omit when using the same-origin proxy |
|
|
86
117
|
| `systems` | `System[]` | — | Pass-through: system data |
|
|
87
118
|
| `incidents` | `Incident[]` | — | Pass-through: incident data |
|
|
88
119
|
| `statusHistory` | `Record<string, StatusHistoryEntry[]>` | — | Pass-through: uptime data |
|
|
@@ -93,18 +124,17 @@ Full status page with systems, incidents, and uptime history bars. Supports **se
|
|
|
93
124
|
Compact inline status indicator — good for footers or nav bars.
|
|
94
125
|
|
|
95
126
|
```svelte
|
|
96
|
-
<StatusBanner
|
|
97
|
-
apiUrl="
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
/>
|
|
127
|
+
<StatusBanner
|
|
128
|
+
apiUrl="/svc/feedback"
|
|
129
|
+
statusUrl="/status"
|
|
130
|
+
/>
|
|
101
131
|
```
|
|
102
132
|
|
|
103
133
|
| Prop | Type | Default | Description |
|
|
104
134
|
|------|------|---------|-------------|
|
|
105
135
|
| `systems` | `System[]` | — | Pass-through mode |
|
|
106
|
-
| `apiUrl` | `string` | — |
|
|
107
|
-
| `apiKey` | `string` | — |
|
|
136
|
+
| `apiUrl` | `string` | — | Proxy URL or direct admin backend URL |
|
|
137
|
+
| `apiKey` | `string` | — | Project API key; omit when using the same-origin proxy |
|
|
108
138
|
| `statusUrl` | `string` | — | Links to full status page (renders as `<a>`) |
|
|
109
139
|
|
|
110
140
|
### FeedbackButton
|
|
@@ -112,19 +142,18 @@ Compact inline status indicator — good for footers or nav bars.
|
|
|
112
142
|
Floating action button with an embedded feedback form. Fixed position, bottom corner.
|
|
113
143
|
|
|
114
144
|
```svelte
|
|
115
|
-
<FeedbackButton
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
userEmail={currentUser?.email}
|
|
145
|
+
<FeedbackButton
|
|
146
|
+
apiUrl="/svc/feedback"
|
|
147
|
+
position="bottom-right"
|
|
148
|
+
categories={['Bug', 'Feature Request', 'General']}
|
|
149
|
+
userEmail={currentUser?.email}
|
|
121
150
|
/>
|
|
122
151
|
```
|
|
123
152
|
|
|
124
153
|
| Prop | Type | Default | Description |
|
|
125
154
|
|------|------|---------|-------------|
|
|
126
|
-
| `apiUrl` | `string` | required |
|
|
127
|
-
| `apiKey` | `string` |
|
|
155
|
+
| `apiUrl` | `string` | required | Proxy URL or direct admin backend URL |
|
|
156
|
+
| `apiKey` | `string` | — | Project API key; omit when using the same-origin proxy |
|
|
128
157
|
| `categories` | `string[]` | `['Bug', 'Feature Request', 'General']` | Category options |
|
|
129
158
|
| `userEmail` | `string` | — | Pre-fill email field |
|
|
130
159
|
| `position` | `'bottom-right' \| 'bottom-left'` | `'bottom-right'` | Button position |
|
|
@@ -134,11 +163,10 @@ Floating action button with an embedded feedback form. Fixed position, bottom co
|
|
|
134
163
|
Standalone feedback form — embed it wherever you need it.
|
|
135
164
|
|
|
136
165
|
```svelte
|
|
137
|
-
<FeedbackForm
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
userEmail={currentUser?.email}
|
|
166
|
+
<FeedbackForm
|
|
167
|
+
apiUrl="/svc/feedback"
|
|
168
|
+
categories={['Bug', 'Feature Request']}
|
|
169
|
+
userEmail={currentUser?.email}
|
|
142
170
|
onSuccess={() => toast('Thanks!')}
|
|
143
171
|
onError={(msg) => toast(msg, 'error')}
|
|
144
172
|
/>
|
|
@@ -146,8 +174,8 @@ Standalone feedback form — embed it wherever you need it.
|
|
|
146
174
|
|
|
147
175
|
| Prop | Type | Default | Description |
|
|
148
176
|
|------|------|---------|-------------|
|
|
149
|
-
| `apiUrl` | `string` | required |
|
|
150
|
-
| `apiKey` | `string` |
|
|
177
|
+
| `apiUrl` | `string` | required | Proxy URL or direct admin backend URL |
|
|
178
|
+
| `apiKey` | `string` | — | Project API key; omit when using the same-origin proxy |
|
|
151
179
|
| `categories` | `string[]` | `['Bug', 'Feature Request', 'General']` | Category options |
|
|
152
180
|
| `userEmail` | `string` | — | Pre-fill email field |
|
|
153
181
|
| `onSuccess` | `() => void` | — | Called after successful submission |
|
|
@@ -171,12 +199,11 @@ The board is composed from parts around **one shared state** (`createFeedbackBoa
|
|
|
171
199
|
type BoardItem,
|
|
172
200
|
} from '@alliance-droid/status-feedback-system';
|
|
173
201
|
|
|
174
|
-
let { data } = $props();
|
|
175
|
-
const board = createFeedbackBoard({
|
|
176
|
-
apiUrl: '
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
getAuthToken: createAuthTokenGetter(), // optional — enables status management
|
|
202
|
+
let { data } = $props();
|
|
203
|
+
const board = createFeedbackBoard({
|
|
204
|
+
apiUrl: '/svc/feedback',
|
|
205
|
+
userEmail: data.user?.email,
|
|
206
|
+
getAuthToken: createAuthTokenGetter(), // optional — enables status management
|
|
180
207
|
categoryConfig: {
|
|
181
208
|
Bug: { icon: 'fa-solid fa-bug', label: 'Bugs' },
|
|
182
209
|
'Feature Request': { icon: 'fa-solid fa-lightbulb', label: 'Ideas' },
|
|
@@ -198,8 +225,8 @@ The board is composed from parts around **one shared state** (`createFeedbackBoa
|
|
|
198
225
|
|
|
199
226
|
| Option | Type | Default | Description |
|
|
200
227
|
|--------|------|---------|-------------|
|
|
201
|
-
| `apiUrl` | `string` | required |
|
|
202
|
-
| `apiKey` | `string` |
|
|
228
|
+
| `apiUrl` | `string` | required | Proxy URL or direct admin backend URL |
|
|
229
|
+
| `apiKey` | `string` | — | Project API key; omit when using the same-origin proxy |
|
|
203
230
|
| `userEmail` | `string` | — | Authenticated user (required to submit feedback/replies) |
|
|
204
231
|
| `categoryConfig` | `Record<string, { label?, icon?, description? }>` | `{}` | Relabel / add icons to sidebar categories |
|
|
205
232
|
| `getAuthToken` | `() => string \| Promise<string \| null>` | — | Bearer token so privileged users can set status (see below) |
|
|
@@ -209,16 +236,18 @@ Exported parts: `createFeedbackBoard`, `FeedbackSidebar`, `FeedbackList`, `Feedb
|
|
|
209
236
|
|
|
210
237
|
Categories are fixed by the system (**Bug**, **Feature Request**) — `categoryConfig` only relabels or adds icons.
|
|
211
238
|
|
|
212
|
-
#### Setting status (
|
|
239
|
+
#### Setting status (ProductOwner)
|
|
240
|
+
|
|
241
|
+
When you pass `getAuthToken`, a signed-in **ProductOwner** with access to the project can change an item's status from the board (via the status badge → menu) — e.g. Open → Considering → Accepted, or a decline with a reason — without a trip to the admin dashboard. Board status management is **ProductOwner-only**; an Admin role alone does not grant it (Admins still manage status from the dashboard).
|
|
213
242
|
|
|
214
|
-
|
|
243
|
+
Privileged viewers also see each item's **real internal status** on the badge (e.g. "QA / UAT", "Technical review") rather than the coarse public bucket ("In progress") that everyone else sees.
|
|
215
244
|
|
|
216
|
-
- `getAuthToken` must return the user's **Alliance OIDC access token** whose audience covers **`status-feedback-admin`** (an *id token*, whose `aud` is your app's client id, is rejected). It's called on demand; return `null` for anonymous / non-privileged users and the control simply won't appear.
|
|
245
|
+
- `getAuthToken` must return the user's **Alliance OIDC access token** whose audience covers **`status-feedback-admin`** (an *id token*, whose `aud` is your app's client id, is rejected). It's called on demand; return `null` for anonymous / non-privileged users and the control simply won't appear — the badge stays a plain read-only badge.
|
|
217
246
|
- **Use the built-in `createAuthTokenGetter()`** for this — it fetches a same-origin token endpoint (`/auth/access-token` by default), caches until just before expiry, single-flights, and returns `null` on any failure. With `@alliance-droid/svelte-auth-core`, set `enableAccessTokenEndpoint: true` and request the `status-feedback-admin` scope so that endpoint exists. Create one instance at module level: `export const getAuthToken = createAuthTokenGetter();`
|
|
218
|
-
- The token travels as `Authorization: Bearer …` to the admin backend, which **verifies it against the IdP** and derives the user's roles. Authorization (per-project access + which transitions
|
|
247
|
+
- The token travels as `Authorization: Bearer …` to the admin backend, which **verifies it against the IdP** and derives the user's roles. Authorization (ProductOwner role + per-project access + which transitions are allowed, including reason prompts on declines/reopens) is enforced **server-side** — the client never decides permissions. The backend returns a `canManage` flag (HTTP 200, not 403) so ordinary signed-in users get no console noise.
|
|
219
248
|
- The available transitions are returned by the backend per item, so the menu always reflects exactly what that user may do.
|
|
220
249
|
|
|
221
|
-
> Security note: the project `apiKey`
|
|
250
|
+
> Security note: use the SvelteKit proxy to keep the project `apiKey` out of browser-delivered code. Direct admin calls with an embedded key are still supported for existing integrations, but same-origin proxy mode is the recommended default.
|
|
222
251
|
|
|
223
252
|
### DevFeedback
|
|
224
253
|
|
|
@@ -234,19 +263,18 @@ Auto-captures: screenshots (via html2canvas), console errors, viewport size, cur
|
|
|
234
263
|
</script>
|
|
235
264
|
|
|
236
265
|
<!-- In your root +layout.svelte -->
|
|
237
|
-
<DevFeedback
|
|
238
|
-
apiUrl="
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
enabled={userCanGiveFeedback}
|
|
266
|
+
<DevFeedback
|
|
267
|
+
apiUrl="/svc/feedback/dev"
|
|
268
|
+
boardUrl="/feedback"
|
|
269
|
+
userEmail={user?.email ?? ''}
|
|
270
|
+
enabled={userCanGiveFeedback}
|
|
243
271
|
/>
|
|
244
272
|
```
|
|
245
273
|
|
|
246
274
|
| Prop | Type | Default | Description |
|
|
247
275
|
|------|------|---------|-------------|
|
|
248
|
-
| `apiUrl` | `string` | required |
|
|
249
|
-
| `apiKey` | `string` |
|
|
276
|
+
| `apiUrl` | `string` | required | Proxy URL or direct admin backend URL. Use `/svc/feedback/dev` with the package proxy for stricter widget policy. |
|
|
277
|
+
| `apiKey` | `string` | — | Project API key; omit when using the same-origin proxy |
|
|
250
278
|
| `enabled` | `boolean` | required | The only gate — wire to your role/permission check |
|
|
251
279
|
| `userEmail` | `string` | required | Signed-in user's email. Empty string ⇒ renders nothing (anonymous feedback unsupported) |
|
|
252
280
|
| `boardUrl` | `string` | — | URL for "View Open Issues" link. Hidden if omitted. |
|
|
@@ -263,11 +291,10 @@ All components use a shared API client. You can also use it directly:
|
|
|
263
291
|
```typescript
|
|
264
292
|
import { createStatusClient } from '@alliance-droid/status-feedback-system';
|
|
265
293
|
|
|
266
|
-
const client = createStatusClient({
|
|
267
|
-
apiUrl: '
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
});
|
|
294
|
+
const client = createStatusClient({
|
|
295
|
+
apiUrl: '/svc/feedback',
|
|
296
|
+
fetch: event.fetch, // Optional: pass SvelteKit's fetch for SSR
|
|
297
|
+
});
|
|
271
298
|
|
|
272
299
|
const { systems, overallStatus } = await client.getStatus();
|
|
273
300
|
const { incidents } = await client.getIncidents({ limit: 5, includeResolved: true });
|
|
@@ -309,15 +336,27 @@ import type {
|
|
|
309
336
|
AuthTokenGetter, AuthTokenGetterOptions,
|
|
310
337
|
} from '@alliance-droid/status-feedback-system';
|
|
311
338
|
|
|
312
|
-
// API Client
|
|
313
|
-
import { createStatusClient } from '@alliance-droid/status-feedback-system';
|
|
314
|
-
import type {
|
|
339
|
+
// API Client
|
|
340
|
+
import { createStatusClient } from '@alliance-droid/status-feedback-system';
|
|
341
|
+
import type {
|
|
342
|
+
StatusClient, StatusClientConfig,
|
|
343
|
+
StatusData, IncidentData, UptimeData, BoardData, BoardItem, BoardCategory,
|
|
344
|
+
} from '@alliance-droid/status-feedback-system';
|
|
345
|
+
|
|
346
|
+
// SvelteKit server proxy
|
|
347
|
+
import {
|
|
348
|
+
createSvelteKitFeedbackProxy,
|
|
349
|
+
isFeedbackProxyConfigured,
|
|
350
|
+
} from '@alliance-droid/status-feedback-system/sveltekit/server';
|
|
351
|
+
|
|
352
|
+
// Utilities
|
|
353
|
+
import { deriveOverallStatus } from '@alliance-droid/status-feedback-system';
|
|
315
354
|
|
|
316
355
|
// Types
|
|
317
356
|
import type {
|
|
318
|
-
System, Incident, IncidentUpdate, Feedback, FeedbackResponse,
|
|
357
|
+
Project, System, Incident, IncidentUpdate, Feedback, FeedbackResponse,
|
|
358
|
+
FeedbackVisibility, StatusHistoryEntry, ApiResponse,
|
|
319
359
|
SystemStatus, IncidentStatus, IncidentSeverity, FeedbackStatus,
|
|
320
|
-
StatusData, IncidentData, UptimeData, BoardData, BoardItem,
|
|
321
360
|
} from '@alliance-droid/status-feedback-system';
|
|
322
361
|
```
|
|
323
362
|
|
package/dist/api.d.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*
|
|
7
7
|
* const client = createStatusClient({
|
|
8
8
|
* apiUrl: 'https://status-admin.yourcompany.com',
|
|
9
|
-
* apiKey: 'sf_abc123...',
|
|
9
|
+
* apiKey: 'sf_abc123...', // omit when apiUrl points at a same-origin proxy
|
|
10
10
|
* });
|
|
11
11
|
*
|
|
12
12
|
* const { systems, incidents } = await client.getStatus();
|
|
@@ -15,8 +15,8 @@ import type { System, Incident, StatusHistoryEntry, Feedback, FeedbackResponse,
|
|
|
15
15
|
export interface StatusClientConfig {
|
|
16
16
|
/** Base URL of the status-feedback-admin app */
|
|
17
17
|
apiUrl: string;
|
|
18
|
-
/** API key for
|
|
19
|
-
apiKey
|
|
18
|
+
/** API key for direct admin calls. Omit when using a same-origin proxy. */
|
|
19
|
+
apiKey?: string;
|
|
20
20
|
/** Optional custom fetch function (for SSR) */
|
|
21
21
|
fetch?: typeof globalThis.fetch;
|
|
22
22
|
}
|
package/dist/api.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*
|
|
7
7
|
* const client = createStatusClient({
|
|
8
8
|
* apiUrl: 'https://status-admin.yourcompany.com',
|
|
9
|
-
* apiKey: 'sf_abc123...',
|
|
9
|
+
* apiKey: 'sf_abc123...', // omit when apiUrl points at a same-origin proxy
|
|
10
10
|
* });
|
|
11
11
|
*
|
|
12
12
|
* const { systems, incidents } = await client.getStatus();
|
|
@@ -18,8 +18,11 @@ export function createStatusClient(config) {
|
|
|
18
18
|
const { apiUrl, apiKey } = config;
|
|
19
19
|
const fetchFn = config.fetch ?? globalThis.fetch;
|
|
20
20
|
const baseUrl = apiUrl.replace(/\/$/, '');
|
|
21
|
+
const resolvedApiKey = apiKey?.trim() ?? '';
|
|
21
22
|
async function request(path, options) {
|
|
22
|
-
const url =
|
|
23
|
+
const url = resolvedApiKey
|
|
24
|
+
? `${baseUrl}${path}${path.includes('?') ? '&' : '?'}key=${encodeURIComponent(resolvedApiKey)}`
|
|
25
|
+
: `${baseUrl}${path}`;
|
|
23
26
|
const response = await fetchFn(url, {
|
|
24
27
|
...options,
|
|
25
28
|
headers: {
|
|
@@ -20,9 +20,10 @@
|
|
|
20
20
|
getElementHtml,
|
|
21
21
|
} from './dev/environment.js';
|
|
22
22
|
|
|
23
|
-
interface Props {
|
|
24
|
-
apiUrl: string;
|
|
25
|
-
|
|
23
|
+
interface Props {
|
|
24
|
+
apiUrl: string;
|
|
25
|
+
/** Omit when apiUrl points at a same-origin proxy. */
|
|
26
|
+
apiKey?: string;
|
|
26
27
|
/**
|
|
27
28
|
* The sole gate for the widget — wire it to your role/permission check
|
|
28
29
|
* (e.g. `enabled={userHasFeedbackRole}`). When false, nothing renders and
|
|
@@ -51,9 +52,9 @@
|
|
|
51
52
|
renderTokenEndpoint?: string;
|
|
52
53
|
}
|
|
53
54
|
|
|
54
|
-
let {
|
|
55
|
-
apiUrl,
|
|
56
|
-
apiKey,
|
|
55
|
+
let {
|
|
56
|
+
apiUrl,
|
|
57
|
+
apiKey = '',
|
|
57
58
|
enabled,
|
|
58
59
|
maxConsoleErrors = 20,
|
|
59
60
|
showOnboarding = true,
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
interface Props {
|
|
2
2
|
apiUrl: string;
|
|
3
|
-
|
|
3
|
+
/** Omit when apiUrl points at a same-origin proxy. */
|
|
4
|
+
apiKey?: string;
|
|
4
5
|
/**
|
|
5
6
|
* The sole gate for the widget — wire it to your role/permission check
|
|
6
7
|
* (e.g. `enabled={userHasFeedbackRole}`). When false, nothing renders and
|
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
-
import FeedbackForm from './FeedbackForm.svelte';
|
|
3
|
-
|
|
4
|
-
interface Props {
|
|
5
|
-
|
|
6
|
-
|
|
2
|
+
import FeedbackForm from './FeedbackForm.svelte';
|
|
3
|
+
|
|
4
|
+
interface Props {
|
|
5
|
+
/** Omit when apiUrl points at a same-origin proxy. */
|
|
6
|
+
apiKey?: string;
|
|
7
|
+
apiUrl: string;
|
|
7
8
|
categories?: string[];
|
|
8
9
|
userEmail?: string;
|
|
9
10
|
position?: 'bottom-right' | 'bottom-left';
|
|
10
11
|
}
|
|
11
12
|
|
|
12
|
-
let {
|
|
13
|
-
apiKey,
|
|
13
|
+
let {
|
|
14
|
+
apiKey = '',
|
|
14
15
|
apiUrl,
|
|
15
16
|
categories,
|
|
16
17
|
userEmail,
|
|
@@ -1,18 +1,19 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import { untrack } from 'svelte';
|
|
3
|
-
import { createStatusClient, type StatusClient } from '../api.js';
|
|
4
|
-
|
|
5
|
-
interface Props {
|
|
6
|
-
|
|
7
|
-
|
|
3
|
+
import { createStatusClient, type StatusClient } from '../api.js';
|
|
4
|
+
|
|
5
|
+
interface Props {
|
|
6
|
+
/** Omit when apiUrl points at a same-origin proxy. */
|
|
7
|
+
apiKey?: string;
|
|
8
|
+
apiUrl: string;
|
|
8
9
|
categories?: string[];
|
|
9
10
|
userEmail?: string;
|
|
10
11
|
onSuccess?: () => void;
|
|
11
12
|
onError?: (error: string) => void;
|
|
12
13
|
}
|
|
13
14
|
|
|
14
|
-
let {
|
|
15
|
-
apiKey,
|
|
15
|
+
let {
|
|
16
|
+
apiKey = '',
|
|
16
17
|
apiUrl,
|
|
17
18
|
categories = ['Bug', 'Feature Request', 'General'],
|
|
18
19
|
userEmail,
|
|
@@ -10,34 +10,35 @@
|
|
|
10
10
|
systems?: System[];
|
|
11
11
|
/** API URL for self-fetching mode */
|
|
12
12
|
apiUrl?: string;
|
|
13
|
-
|
|
14
|
-
|
|
13
|
+
/** API key for direct admin calls. Omit when using a same-origin proxy. */
|
|
14
|
+
apiKey?: string;
|
|
15
15
|
/** Link to full status page */
|
|
16
16
|
statusUrl?: string;
|
|
17
17
|
/** Auto-refresh interval in seconds. 0 = disabled. Default: 60 */
|
|
18
18
|
refreshInterval?: number;
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
-
let { systems: systemsProp, apiUrl, apiKey, statusUrl, refreshInterval = 60 }: Props = $props();
|
|
22
|
-
|
|
23
|
-
let systems = $derived(systemsProp ?? []);
|
|
24
|
-
let loading = $derived(!systemsProp && !!apiUrl
|
|
25
|
-
let fetchFailed = $state(false);
|
|
26
|
-
|
|
27
|
-
let usesApi = $derived(!systemsProp && !!apiUrl
|
|
21
|
+
let { systems: systemsProp, apiUrl, apiKey, statusUrl, refreshInterval = 60 }: Props = $props();
|
|
22
|
+
|
|
23
|
+
let systems = $derived(systemsProp ?? []);
|
|
24
|
+
let loading = $derived(!systemsProp && !!apiUrl);
|
|
25
|
+
let fetchFailed = $state(false);
|
|
26
|
+
|
|
27
|
+
let usesApi = $derived(!systemsProp && !!apiUrl);
|
|
28
28
|
|
|
29
29
|
let cachedClient: StatusClient | null = null;
|
|
30
|
-
let cachedApiUrl = '';
|
|
31
|
-
let cachedApiKey = '';
|
|
32
|
-
|
|
33
|
-
function getClient(): StatusClient {
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
30
|
+
let cachedApiUrl = '';
|
|
31
|
+
let cachedApiKey = '';
|
|
32
|
+
|
|
33
|
+
function getClient(): StatusClient {
|
|
34
|
+
const nextApiKey = apiKey ?? '';
|
|
35
|
+
if (!cachedClient || cachedApiUrl !== apiUrl || cachedApiKey !== nextApiKey) {
|
|
36
|
+
cachedClient = createStatusClient({ apiUrl: apiUrl!, apiKey: nextApiKey });
|
|
37
|
+
cachedApiUrl = apiUrl!;
|
|
38
|
+
cachedApiKey = nextApiKey;
|
|
39
|
+
}
|
|
40
|
+
return cachedClient;
|
|
41
|
+
}
|
|
41
42
|
|
|
42
43
|
async function fetchData() {
|
|
43
44
|
if (!usesApi) return;
|
|
@@ -4,7 +4,7 @@ interface Props {
|
|
|
4
4
|
systems?: System[];
|
|
5
5
|
/** API URL for self-fetching mode */
|
|
6
6
|
apiUrl?: string;
|
|
7
|
-
/** API key for
|
|
7
|
+
/** API key for direct admin calls. Omit when using a same-origin proxy. */
|
|
8
8
|
apiKey?: string;
|
|
9
9
|
/** Link to full status page */
|
|
10
10
|
statusUrl?: string;
|
|
@@ -12,8 +12,8 @@
|
|
|
12
12
|
projectName: string;
|
|
13
13
|
/** API URL for fetching data (self-fetching mode) */
|
|
14
14
|
apiUrl?: string;
|
|
15
|
-
|
|
16
|
-
|
|
15
|
+
/** API key for direct admin calls. Omit when using a same-origin proxy. */
|
|
16
|
+
apiKey?: string;
|
|
17
17
|
/** Systems data (pass-through mode — overrides API fetching) */
|
|
18
18
|
systems?: System[];
|
|
19
19
|
/** Incidents data (pass-through mode) */
|
|
@@ -44,21 +44,22 @@
|
|
|
44
44
|
let error = $state('');
|
|
45
45
|
|
|
46
46
|
// ── Self-fetching mode ──
|
|
47
|
-
const usesApi = $derived(!!apiUrl &&
|
|
48
|
-
let refreshTimer: ReturnType<typeof setInterval> | null = null;
|
|
47
|
+
const usesApi = $derived(!!apiUrl && !systemsProp);
|
|
48
|
+
let refreshTimer: ReturnType<typeof setInterval> | null = null;
|
|
49
49
|
|
|
50
50
|
let cachedClient: StatusClient | null = null;
|
|
51
|
-
let cachedApiUrl = '';
|
|
52
|
-
let cachedApiKey = '';
|
|
53
|
-
|
|
54
|
-
function getClient(): StatusClient {
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
51
|
+
let cachedApiUrl = '';
|
|
52
|
+
let cachedApiKey = '';
|
|
53
|
+
|
|
54
|
+
function getClient(): StatusClient {
|
|
55
|
+
const nextApiKey = apiKey ?? '';
|
|
56
|
+
if (!cachedClient || cachedApiUrl !== apiUrl || cachedApiKey !== nextApiKey) {
|
|
57
|
+
cachedClient = createStatusClient({ apiUrl: apiUrl!, apiKey: nextApiKey });
|
|
58
|
+
cachedApiUrl = apiUrl!;
|
|
59
|
+
cachedApiKey = nextApiKey;
|
|
60
|
+
}
|
|
61
|
+
return cachedClient;
|
|
62
|
+
}
|
|
62
63
|
|
|
63
64
|
async function fetchData() {
|
|
64
65
|
if (!usesApi) return;
|
|
@@ -4,7 +4,7 @@ interface Props {
|
|
|
4
4
|
projectName: string;
|
|
5
5
|
/** API URL for fetching data (self-fetching mode) */
|
|
6
6
|
apiUrl?: string;
|
|
7
|
-
/** API key for
|
|
7
|
+
/** API key for direct admin calls. Omit when using a same-origin proxy. */
|
|
8
8
|
apiKey?: string;
|
|
9
9
|
/** Systems data (pass-through mode — overrides API fetching) */
|
|
10
10
|
systems?: System[];
|
|
@@ -15,7 +15,8 @@ export interface CategoryConfig {
|
|
|
15
15
|
}
|
|
16
16
|
export interface FeedbackBoardOptions {
|
|
17
17
|
apiUrl: string;
|
|
18
|
-
|
|
18
|
+
/** Omit when apiUrl points at a same-origin proxy. */
|
|
19
|
+
apiKey?: string;
|
|
19
20
|
categoryConfig?: Record<string, CategoryConfig>;
|
|
20
21
|
/** Authenticated user email. Required for submitting feedback and replies. */
|
|
21
22
|
userEmail?: string;
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
import { FEEDBACK_CATEGORY_ICONS } from '../../types.js';
|
|
10
10
|
import { createStatusClient, } from '../../api.js';
|
|
11
11
|
export function createFeedbackBoard(options) {
|
|
12
|
-
const { apiUrl, apiKey, categoryConfig = {}, userEmail, getAuthToken, pageSize = 50 } = options;
|
|
12
|
+
const { apiUrl, apiKey = '', categoryConfig = {}, userEmail, getAuthToken, pageSize = 50 } = options;
|
|
13
13
|
const client = createStatusClient({ apiUrl, apiKey });
|
|
14
14
|
const resolvedEmail = userEmail?.trim() || null;
|
|
15
15
|
// Reactive state
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SvelteKit server adapter for hiding status-feedback-admin project API keys
|
|
3
|
+
* behind a same-origin proxy route.
|
|
4
|
+
*
|
|
5
|
+
* Recommended consumer route:
|
|
6
|
+
*
|
|
7
|
+
* // src/routes/svc/feedback/[...path]/+server.ts
|
|
8
|
+
* import { createSvelteKitFeedbackProxy } from '@alliance-droid/status-feedback-system/sveltekit/server';
|
|
9
|
+
*
|
|
10
|
+
* export const { GET, POST, PATCH } = createSvelteKitFeedbackProxy({
|
|
11
|
+
* apiUrl: env.SF_API_URL,
|
|
12
|
+
* apiKey: env.SF_API_KEY,
|
|
13
|
+
* resolveUser,
|
|
14
|
+
* });
|
|
15
|
+
*/
|
|
16
|
+
type MaybePromise<T> = T | Promise<T>;
|
|
17
|
+
type ConfigValue = string | null | undefined | (() => MaybePromise<string | null | undefined>);
|
|
18
|
+
export type FeedbackProxySource = 'default' | 'devFeedback';
|
|
19
|
+
export type FeedbackProxyOperation = 'status' | 'incidents' | 'uptime' | 'submitFeedback' | 'board' | 'vote' | 'responses' | 'submitResponse' | 'transitions' | 'updateStatus';
|
|
20
|
+
export interface FeedbackProxyUser {
|
|
21
|
+
email?: string | null;
|
|
22
|
+
roles?: readonly string[];
|
|
23
|
+
}
|
|
24
|
+
export interface SvelteKitFeedbackProxyEvent {
|
|
25
|
+
request: Request;
|
|
26
|
+
url: URL;
|
|
27
|
+
params?: Record<string, string | undefined>;
|
|
28
|
+
fetch?: typeof globalThis.fetch;
|
|
29
|
+
}
|
|
30
|
+
export interface FeedbackProxyContext<User> {
|
|
31
|
+
event: SvelteKitFeedbackProxyEvent;
|
|
32
|
+
user: User | null;
|
|
33
|
+
operation: FeedbackProxyOperation;
|
|
34
|
+
source: FeedbackProxySource;
|
|
35
|
+
}
|
|
36
|
+
export interface FeedbackProxyRequireUserContext {
|
|
37
|
+
event: SvelteKitFeedbackProxyEvent;
|
|
38
|
+
operation: FeedbackProxyOperation;
|
|
39
|
+
source: FeedbackProxySource;
|
|
40
|
+
}
|
|
41
|
+
export interface SvelteKitFeedbackProxyOptions<User = FeedbackProxyUser> {
|
|
42
|
+
/** Base URL of the status-feedback-admin app. Keep this server-side. */
|
|
43
|
+
apiUrl: ConfigValue;
|
|
44
|
+
/** Project API key. Keep this server-side. */
|
|
45
|
+
apiKey: ConfigValue;
|
|
46
|
+
/** Resolve the host app's authenticated user, or null when unauthenticated. */
|
|
47
|
+
resolveUser?: (event: SvelteKitFeedbackProxyEvent) => MaybePromise<User | null>;
|
|
48
|
+
/** Extract an email from the resolved user. Defaults to `user.email`. */
|
|
49
|
+
getUserEmail?: (user: User) => string | null | undefined;
|
|
50
|
+
/**
|
|
51
|
+
* Require an authenticated user before proxying. Use `true` for private apps,
|
|
52
|
+
* or a callback when public status reads should remain anonymous.
|
|
53
|
+
*/
|
|
54
|
+
requireUser?: boolean | ((context: FeedbackProxyRequireUserContext) => MaybePromise<boolean>);
|
|
55
|
+
/**
|
|
56
|
+
* Additional host-app authorization. Return false for a standard 403 or a
|
|
57
|
+
* Response for a custom denial body/status.
|
|
58
|
+
*/
|
|
59
|
+
authorize?: (context: FeedbackProxyContext<User>) => MaybePromise<boolean | Response | void>;
|
|
60
|
+
/**
|
|
61
|
+
* Extra gate for DevFeedback submissions. Triggered for requests under a
|
|
62
|
+
* `dev/` prefix, such as `/svc/feedback/dev/api/feedback`.
|
|
63
|
+
*/
|
|
64
|
+
canSubmitInAppFeedback?: (context: FeedbackProxyContext<User>) => MaybePromise<boolean>;
|
|
65
|
+
/**
|
|
66
|
+
* When true, feedback `email` and response `author` are derived from the
|
|
67
|
+
* authenticated user. Defaults to true when `resolveUser` is supplied.
|
|
68
|
+
*/
|
|
69
|
+
deriveUserFields?: boolean;
|
|
70
|
+
/** Rest parameter name used by the SvelteKit catch-all route. Default: path. */
|
|
71
|
+
pathParam?: string;
|
|
72
|
+
/** Optional fetch override for tests. Defaults to event.fetch, then global fetch. */
|
|
73
|
+
fetch?: typeof globalThis.fetch;
|
|
74
|
+
}
|
|
75
|
+
export interface SvelteKitFeedbackProxyHandlers {
|
|
76
|
+
GET: (event: SvelteKitFeedbackProxyEvent) => Promise<Response>;
|
|
77
|
+
POST: (event: SvelteKitFeedbackProxyEvent) => Promise<Response>;
|
|
78
|
+
PATCH: (event: SvelteKitFeedbackProxyEvent) => Promise<Response>;
|
|
79
|
+
}
|
|
80
|
+
export declare function createSvelteKitFeedbackProxy<User = FeedbackProxyUser>(options: SvelteKitFeedbackProxyOptions<User>): SvelteKitFeedbackProxyHandlers;
|
|
81
|
+
export declare function isFeedbackProxyConfigured(config: {
|
|
82
|
+
apiUrl: string | null | undefined;
|
|
83
|
+
apiKey: string | null | undefined;
|
|
84
|
+
}): boolean;
|
|
85
|
+
export {};
|
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SvelteKit server adapter for hiding status-feedback-admin project API keys
|
|
3
|
+
* behind a same-origin proxy route.
|
|
4
|
+
*
|
|
5
|
+
* Recommended consumer route:
|
|
6
|
+
*
|
|
7
|
+
* // src/routes/svc/feedback/[...path]/+server.ts
|
|
8
|
+
* import { createSvelteKitFeedbackProxy } from '@alliance-droid/status-feedback-system/sveltekit/server';
|
|
9
|
+
*
|
|
10
|
+
* export const { GET, POST, PATCH } = createSvelteKitFeedbackProxy({
|
|
11
|
+
* apiUrl: env.SF_API_URL,
|
|
12
|
+
* apiKey: env.SF_API_KEY,
|
|
13
|
+
* resolveUser,
|
|
14
|
+
* });
|
|
15
|
+
*/
|
|
16
|
+
const INCIDENT_QUERY_KEYS = ['limit', 'includeResolved'];
|
|
17
|
+
const UPTIME_QUERY_KEYS = ['days'];
|
|
18
|
+
const BOARD_QUERY_KEYS = ['category', 'status', 'sort', 'limit', 'offset'];
|
|
19
|
+
export function createSvelteKitFeedbackProxy(options) {
|
|
20
|
+
return {
|
|
21
|
+
GET: (event) => handleProxyRequest(event, options),
|
|
22
|
+
POST: (event) => handleProxyRequest(event, options),
|
|
23
|
+
PATCH: (event) => handleProxyRequest(event, options),
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
export function isFeedbackProxyConfigured(config) {
|
|
27
|
+
return Boolean(config.apiUrl?.trim() && config.apiKey?.trim());
|
|
28
|
+
}
|
|
29
|
+
async function handleProxyRequest(event, options) {
|
|
30
|
+
const resolvedPath = getResolvedPath(event, options.pathParam ?? 'path');
|
|
31
|
+
const route = getRouteSpec(event.request.method, resolvedPath.path);
|
|
32
|
+
if (!route)
|
|
33
|
+
return feedbackError(404, 'Feedback endpoint not found');
|
|
34
|
+
const user = options.resolveUser ? await options.resolveUser(event) : null;
|
|
35
|
+
const requireUser = await shouldRequireUser(event, options, route.operation, resolvedPath.source);
|
|
36
|
+
if (requireUser && !user)
|
|
37
|
+
return feedbackError(401, 'Unauthorized');
|
|
38
|
+
const authResponse = await authorizeRequest(event, options, user, route.operation, resolvedPath.source);
|
|
39
|
+
if (authResponse)
|
|
40
|
+
return authResponse;
|
|
41
|
+
if (resolvedPath.source === 'devFeedback' && route.operation === 'submitFeedback') {
|
|
42
|
+
if (!user)
|
|
43
|
+
return feedbackError(401, 'Unauthorized');
|
|
44
|
+
if (options.canSubmitInAppFeedback) {
|
|
45
|
+
const allowed = await options.canSubmitInAppFeedback({
|
|
46
|
+
event,
|
|
47
|
+
user,
|
|
48
|
+
operation: route.operation,
|
|
49
|
+
source: resolvedPath.source,
|
|
50
|
+
});
|
|
51
|
+
if (!allowed) {
|
|
52
|
+
return feedbackError(403, 'Forbidden: in-app feedback is not allowed for this user');
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
const config = await getResolvedConfig(options);
|
|
57
|
+
if (!config)
|
|
58
|
+
return feedbackError(503, 'Feedback service is not configured');
|
|
59
|
+
const upstreamUrl = buildUpstreamUrl(config.apiUrl, route.upstreamPath, copyAllowedSearchParams(event.url.searchParams, route.allowedSearchParams ?? []));
|
|
60
|
+
const headers = new Headers({
|
|
61
|
+
Accept: 'application/json',
|
|
62
|
+
'x-api-key': config.apiKey,
|
|
63
|
+
});
|
|
64
|
+
if (route.forwardBearerToken) {
|
|
65
|
+
const authorization = event.request.headers.get('authorization');
|
|
66
|
+
if (!authorization?.toLowerCase().startsWith('bearer ')) {
|
|
67
|
+
return feedbackError(401, 'Status feedback access token required');
|
|
68
|
+
}
|
|
69
|
+
headers.set('Authorization', authorization);
|
|
70
|
+
}
|
|
71
|
+
const method = event.request.method.toUpperCase();
|
|
72
|
+
let body;
|
|
73
|
+
if (method !== 'GET' && method !== 'HEAD') {
|
|
74
|
+
headers.set('Content-Type', 'application/json');
|
|
75
|
+
const parsedBody = await parseProxyBody(event, options, route.bodyKind, user);
|
|
76
|
+
if (parsedBody instanceof Response)
|
|
77
|
+
return parsedBody;
|
|
78
|
+
body = JSON.stringify(parsedBody);
|
|
79
|
+
}
|
|
80
|
+
try {
|
|
81
|
+
const fetchFn = options.fetch ?? event.fetch ?? globalThis.fetch;
|
|
82
|
+
const upstreamResponse = await fetchFn(upstreamUrl, {
|
|
83
|
+
method,
|
|
84
|
+
headers,
|
|
85
|
+
body,
|
|
86
|
+
});
|
|
87
|
+
return await toProxyResponse(upstreamResponse);
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
return feedbackError(502, 'Feedback service request failed');
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
function getResolvedPath(event, pathParam) {
|
|
94
|
+
let path = event.params?.[pathParam] ?? '';
|
|
95
|
+
path = trimSlashes(path);
|
|
96
|
+
let source = 'default';
|
|
97
|
+
if (path === 'dev' || path.startsWith('dev/')) {
|
|
98
|
+
source = 'devFeedback';
|
|
99
|
+
path = trimSlashes(path.slice(3));
|
|
100
|
+
}
|
|
101
|
+
return { path, source };
|
|
102
|
+
}
|
|
103
|
+
function getRouteSpec(method, path) {
|
|
104
|
+
const normalizedMethod = method.toUpperCase();
|
|
105
|
+
const normalizedPath = normalizeApiPath(path);
|
|
106
|
+
if (normalizedMethod === 'GET' && normalizedPath === 'api/status') {
|
|
107
|
+
return { operation: 'status', upstreamPath: '/api/status', bodyKind: 'none' };
|
|
108
|
+
}
|
|
109
|
+
if (normalizedMethod === 'GET' && normalizedPath === 'api/incidents') {
|
|
110
|
+
return {
|
|
111
|
+
operation: 'incidents',
|
|
112
|
+
upstreamPath: '/api/incidents',
|
|
113
|
+
bodyKind: 'none',
|
|
114
|
+
allowedSearchParams: INCIDENT_QUERY_KEYS,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
if (normalizedMethod === 'GET' && normalizedPath === 'api/uptime') {
|
|
118
|
+
return {
|
|
119
|
+
operation: 'uptime',
|
|
120
|
+
upstreamPath: '/api/uptime',
|
|
121
|
+
bodyKind: 'none',
|
|
122
|
+
allowedSearchParams: UPTIME_QUERY_KEYS,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
if (normalizedMethod === 'POST' && normalizedPath === 'api/feedback') {
|
|
126
|
+
return { operation: 'submitFeedback', upstreamPath: '/api/feedback', bodyKind: 'feedback' };
|
|
127
|
+
}
|
|
128
|
+
if (normalizedMethod === 'GET' && normalizedPath === 'api/board') {
|
|
129
|
+
return {
|
|
130
|
+
operation: 'board',
|
|
131
|
+
upstreamPath: '/api/board',
|
|
132
|
+
bodyKind: 'none',
|
|
133
|
+
allowedSearchParams: BOARD_QUERY_KEYS,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
if (normalizedMethod === 'POST' && normalizedPath === 'api/board/vote') {
|
|
137
|
+
return { operation: 'vote', upstreamPath: '/api/board/vote', bodyKind: 'vote' };
|
|
138
|
+
}
|
|
139
|
+
const responsesMatch = /^api\/board\/([^/]+)\/responses$/.exec(normalizedPath);
|
|
140
|
+
if (responsesMatch && normalizedMethod === 'GET') {
|
|
141
|
+
return {
|
|
142
|
+
operation: 'responses',
|
|
143
|
+
upstreamPath: `/api/board/${encodeURIComponent(responsesMatch[1])}/responses`,
|
|
144
|
+
bodyKind: 'none',
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
if (responsesMatch && normalizedMethod === 'POST') {
|
|
148
|
+
return {
|
|
149
|
+
operation: 'submitResponse',
|
|
150
|
+
upstreamPath: `/api/board/${encodeURIComponent(responsesMatch[1])}/responses`,
|
|
151
|
+
bodyKind: 'response',
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
const transitionsMatch = /^api\/board\/feedback\/([^/]+)\/transitions$/.exec(normalizedPath);
|
|
155
|
+
if (transitionsMatch && normalizedMethod === 'GET') {
|
|
156
|
+
return {
|
|
157
|
+
operation: 'transitions',
|
|
158
|
+
upstreamPath: `/api/board/feedback/${encodeURIComponent(transitionsMatch[1])}/transitions`,
|
|
159
|
+
bodyKind: 'none',
|
|
160
|
+
forwardBearerToken: true,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
const statusMatch = /^api\/board\/feedback\/([^/]+)\/status$/.exec(normalizedPath);
|
|
164
|
+
if (statusMatch && normalizedMethod === 'PATCH') {
|
|
165
|
+
return {
|
|
166
|
+
operation: 'updateStatus',
|
|
167
|
+
upstreamPath: `/api/board/feedback/${encodeURIComponent(statusMatch[1])}/status`,
|
|
168
|
+
bodyKind: 'status',
|
|
169
|
+
forwardBearerToken: true,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
function normalizeApiPath(path) {
|
|
175
|
+
const normalized = trimSlashes(path);
|
|
176
|
+
if (!normalized)
|
|
177
|
+
return 'api';
|
|
178
|
+
return normalized.startsWith('api/') ? normalized : `api/${normalized}`;
|
|
179
|
+
}
|
|
180
|
+
async function getResolvedConfig(options) {
|
|
181
|
+
const apiUrl = (await resolveConfigValue(options.apiUrl))?.trim().replace(/\/+$/, '');
|
|
182
|
+
const apiKey = (await resolveConfigValue(options.apiKey))?.trim();
|
|
183
|
+
if (!apiUrl || !apiKey)
|
|
184
|
+
return null;
|
|
185
|
+
return { apiUrl, apiKey };
|
|
186
|
+
}
|
|
187
|
+
async function resolveConfigValue(value) {
|
|
188
|
+
return typeof value === 'function' ? value() : value;
|
|
189
|
+
}
|
|
190
|
+
async function shouldRequireUser(event, options, operation, source) {
|
|
191
|
+
if (typeof options.requireUser === 'function') {
|
|
192
|
+
return options.requireUser({ event, operation, source });
|
|
193
|
+
}
|
|
194
|
+
return options.requireUser ?? false;
|
|
195
|
+
}
|
|
196
|
+
async function authorizeRequest(event, options, user, operation, source) {
|
|
197
|
+
if (!options.authorize)
|
|
198
|
+
return null;
|
|
199
|
+
const result = await options.authorize({ event, user, operation, source });
|
|
200
|
+
if (result instanceof Response)
|
|
201
|
+
return result;
|
|
202
|
+
if (result === false)
|
|
203
|
+
return feedbackError(403, 'Forbidden');
|
|
204
|
+
return null;
|
|
205
|
+
}
|
|
206
|
+
function copyAllowedSearchParams(source, allowedKeys) {
|
|
207
|
+
const result = new URLSearchParams();
|
|
208
|
+
for (const key of allowedKeys) {
|
|
209
|
+
if (key === 'key')
|
|
210
|
+
continue;
|
|
211
|
+
const value = source.get(key);
|
|
212
|
+
if (value !== null)
|
|
213
|
+
result.set(key, value);
|
|
214
|
+
}
|
|
215
|
+
return result;
|
|
216
|
+
}
|
|
217
|
+
function buildUpstreamUrl(apiUrl, upstreamPath, searchParams) {
|
|
218
|
+
const upstreamUrl = new URL(upstreamPath.replace(/^\/+/, ''), `${apiUrl}/`);
|
|
219
|
+
for (const [key, value] of searchParams) {
|
|
220
|
+
if (key === 'key')
|
|
221
|
+
continue;
|
|
222
|
+
upstreamUrl.searchParams.set(key, value);
|
|
223
|
+
}
|
|
224
|
+
return upstreamUrl;
|
|
225
|
+
}
|
|
226
|
+
async function parseProxyBody(event, options, bodyKind, user) {
|
|
227
|
+
if (bodyKind === 'none')
|
|
228
|
+
return {};
|
|
229
|
+
let rawBody;
|
|
230
|
+
try {
|
|
231
|
+
rawBody = await event.request.json();
|
|
232
|
+
}
|
|
233
|
+
catch {
|
|
234
|
+
return feedbackError(400, 'Invalid JSON body');
|
|
235
|
+
}
|
|
236
|
+
try {
|
|
237
|
+
const deriveUserFields = options.deriveUserFields ?? Boolean(options.resolveUser);
|
|
238
|
+
if (bodyKind === 'feedback') {
|
|
239
|
+
const body = parseFeedbackBody(rawBody);
|
|
240
|
+
if (deriveUserFields) {
|
|
241
|
+
const email = getResolvedUserEmail(options, user);
|
|
242
|
+
if (!email)
|
|
243
|
+
return feedbackError(403, 'Authenticated user email required');
|
|
244
|
+
body.email = email;
|
|
245
|
+
}
|
|
246
|
+
return body;
|
|
247
|
+
}
|
|
248
|
+
if (bodyKind === 'vote')
|
|
249
|
+
return parseVoteBody(rawBody);
|
|
250
|
+
if (bodyKind === 'response') {
|
|
251
|
+
const body = parseResponseBody(rawBody);
|
|
252
|
+
if (deriveUserFields) {
|
|
253
|
+
const email = getResolvedUserEmail(options, user);
|
|
254
|
+
if (!email)
|
|
255
|
+
return feedbackError(403, 'Authenticated user email required');
|
|
256
|
+
body.author = email;
|
|
257
|
+
}
|
|
258
|
+
return body;
|
|
259
|
+
}
|
|
260
|
+
return parseStatusBody(rawBody);
|
|
261
|
+
}
|
|
262
|
+
catch {
|
|
263
|
+
return feedbackError(400, 'Invalid feedback request');
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
function getResolvedUserEmail(options, user) {
|
|
267
|
+
if (!user)
|
|
268
|
+
return null;
|
|
269
|
+
const email = options.getUserEmail
|
|
270
|
+
? options.getUserEmail(user)
|
|
271
|
+
: user.email;
|
|
272
|
+
const normalized = email?.trim();
|
|
273
|
+
return normalized || null;
|
|
274
|
+
}
|
|
275
|
+
function parseFeedbackBody(rawBody) {
|
|
276
|
+
const body = requireRecord(rawBody);
|
|
277
|
+
const result = {
|
|
278
|
+
message: requireString(body, 'message', 1, 20_000),
|
|
279
|
+
};
|
|
280
|
+
assignOptionalString(result, body, 'title', 500);
|
|
281
|
+
assignOptionalString(result, body, 'category', 100);
|
|
282
|
+
assignOptionalString(result, body, 'pageUrl', 2_048);
|
|
283
|
+
assignOptionalString(result, body, 'email', 320);
|
|
284
|
+
if (body.metadata !== undefined) {
|
|
285
|
+
if (!isRecord(body.metadata))
|
|
286
|
+
throw new Error('Invalid metadata');
|
|
287
|
+
result.metadata = body.metadata;
|
|
288
|
+
}
|
|
289
|
+
return result;
|
|
290
|
+
}
|
|
291
|
+
function parseVoteBody(rawBody) {
|
|
292
|
+
const body = requireRecord(rawBody);
|
|
293
|
+
const result = {
|
|
294
|
+
feedbackId: requireString(body, 'feedbackId', 1, 200),
|
|
295
|
+
};
|
|
296
|
+
if (body.direction !== undefined) {
|
|
297
|
+
const direction = requireString(body, 'direction', 1, 10);
|
|
298
|
+
if (direction !== 'up' && direction !== 'down')
|
|
299
|
+
throw new Error('Invalid direction');
|
|
300
|
+
result.direction = direction;
|
|
301
|
+
}
|
|
302
|
+
return result;
|
|
303
|
+
}
|
|
304
|
+
function parseResponseBody(rawBody) {
|
|
305
|
+
const body = requireRecord(rawBody);
|
|
306
|
+
const result = {
|
|
307
|
+
message: requireString(body, 'message', 1, 20_000),
|
|
308
|
+
};
|
|
309
|
+
assignOptionalString(result, body, 'author', 320);
|
|
310
|
+
assignOptionalString(result, body, 'pastedImage');
|
|
311
|
+
return result;
|
|
312
|
+
}
|
|
313
|
+
function parseStatusBody(rawBody) {
|
|
314
|
+
const body = requireRecord(rawBody);
|
|
315
|
+
const result = {
|
|
316
|
+
status: requireString(body, 'status', 1, 100),
|
|
317
|
+
};
|
|
318
|
+
assignOptionalString(result, body, 'reason', 2_000);
|
|
319
|
+
return result;
|
|
320
|
+
}
|
|
321
|
+
function requireRecord(value) {
|
|
322
|
+
if (!isRecord(value))
|
|
323
|
+
throw new Error('Expected object');
|
|
324
|
+
return value;
|
|
325
|
+
}
|
|
326
|
+
function isRecord(value) {
|
|
327
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
328
|
+
}
|
|
329
|
+
function requireString(body, key, minLength, maxLength) {
|
|
330
|
+
const value = body[key];
|
|
331
|
+
if (typeof value !== 'string')
|
|
332
|
+
throw new Error(`Invalid ${key}`);
|
|
333
|
+
const trimmed = value.trim();
|
|
334
|
+
if (trimmed.length < minLength)
|
|
335
|
+
throw new Error(`Invalid ${key}`);
|
|
336
|
+
if (maxLength !== undefined && trimmed.length > maxLength)
|
|
337
|
+
throw new Error(`Invalid ${key}`);
|
|
338
|
+
return trimmed;
|
|
339
|
+
}
|
|
340
|
+
function assignOptionalString(target, body, key, maxLength) {
|
|
341
|
+
const value = body[key];
|
|
342
|
+
if (value === undefined)
|
|
343
|
+
return;
|
|
344
|
+
if (value === null) {
|
|
345
|
+
target[key] = null;
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
if (typeof value !== 'string')
|
|
349
|
+
throw new Error(`Invalid ${key}`);
|
|
350
|
+
const trimmed = value.trim();
|
|
351
|
+
if (maxLength !== undefined && trimmed.length > maxLength)
|
|
352
|
+
throw new Error(`Invalid ${key}`);
|
|
353
|
+
target[key] = trimmed;
|
|
354
|
+
}
|
|
355
|
+
async function toProxyResponse(upstreamResponse) {
|
|
356
|
+
const body = await upstreamResponse.arrayBuffer();
|
|
357
|
+
const headers = new Headers({
|
|
358
|
+
'Cache-Control': 'no-store',
|
|
359
|
+
});
|
|
360
|
+
const contentType = upstreamResponse.headers.get('content-type');
|
|
361
|
+
if (contentType)
|
|
362
|
+
headers.set('Content-Type', contentType);
|
|
363
|
+
return new Response(body, {
|
|
364
|
+
status: upstreamResponse.status,
|
|
365
|
+
headers,
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
function feedbackError(status, message) {
|
|
369
|
+
return new Response(JSON.stringify({
|
|
370
|
+
success: false,
|
|
371
|
+
error: message,
|
|
372
|
+
message,
|
|
373
|
+
}), {
|
|
374
|
+
status,
|
|
375
|
+
headers: {
|
|
376
|
+
'Content-Type': 'application/json',
|
|
377
|
+
'Cache-Control': 'no-store',
|
|
378
|
+
},
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
function trimSlashes(value) {
|
|
382
|
+
return value.replace(/^\/+|\/+$/g, '');
|
|
383
|
+
}
|
package/package.json
CHANGED
|
@@ -1,66 +1,69 @@
|
|
|
1
|
-
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
1
|
+
{
|
|
2
|
+
"name": "@alliance-droid/status-feedback-system",
|
|
3
|
+
"version": "4.2.1",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Drop-in system status pages and user feedback forms for SvelteKit apps",
|
|
6
|
+
"svelte": "./dist/index.js",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"svelte": "./dist/index.js"
|
|
12
|
+
},
|
|
13
|
+
"./sveltekit/server": {
|
|
14
|
+
"types": "./dist/sveltekit/server.d.ts",
|
|
15
|
+
"default": "./dist/sveltekit/server.js"
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"dist",
|
|
20
|
+
"!dist/**/*.test.*",
|
|
21
|
+
"CHANGELOG.md"
|
|
22
|
+
],
|
|
23
|
+
"peerDependencies": {
|
|
24
|
+
"@alliance-droid/svelte-component-library": ">=1.3.2",
|
|
25
|
+
"svelte": "^5.48.0"
|
|
26
|
+
},
|
|
27
|
+
"devDependencies": {
|
|
28
|
+
"@alliance-droid/svelte-component-library": "^1.3.2",
|
|
29
|
+
"@eslint/js": "^10.0.1",
|
|
30
|
+
"@sveltejs/package": "^2.3.0",
|
|
31
|
+
"@sveltejs/vite-plugin-svelte": "^6.0.0",
|
|
32
|
+
"@testing-library/jest-dom": "^6.9.1",
|
|
33
|
+
"@testing-library/svelte": "^5.3.1",
|
|
34
|
+
"eslint": "^10.4.0",
|
|
35
|
+
"eslint-plugin-svelte": "^3.17.1",
|
|
36
|
+
"globals": "^17.6.0",
|
|
37
|
+
"jsdom": "^29.0.0",
|
|
38
|
+
"publint": "^0.3.0",
|
|
39
|
+
"svelte": "^5.48.0",
|
|
40
|
+
"svelte-check": "^4.3.0",
|
|
41
|
+
"typescript": "^5.9.0",
|
|
42
|
+
"typescript-eslint": "^8.59.4",
|
|
43
|
+
"vite": "^7.3.0",
|
|
44
|
+
"vitest": "^4.0.0"
|
|
45
|
+
},
|
|
46
|
+
"keywords": [
|
|
47
|
+
"svelte",
|
|
48
|
+
"sveltekit",
|
|
49
|
+
"status-page",
|
|
50
|
+
"feedback",
|
|
51
|
+
"uptime"
|
|
52
|
+
],
|
|
53
|
+
"author": "Alliance Technical Group",
|
|
54
|
+
"license": "UNLICENSED",
|
|
55
|
+
"publishConfig": {
|
|
56
|
+
"access": "restricted"
|
|
57
|
+
},
|
|
58
|
+
"repository": {
|
|
59
|
+
"type": "git",
|
|
60
|
+
"url": "git+https://alliance.ghe.com/alliance/status-feedback-system.git"
|
|
61
|
+
},
|
|
62
|
+
"scripts": {
|
|
63
|
+
"build": "svelte-package -o dist && publint",
|
|
64
|
+
"lint": "eslint src",
|
|
65
|
+
"check": "svelte-check --tsconfig ./tsconfig.json",
|
|
66
|
+
"test": "vitest run",
|
|
67
|
+
"test:watch": "vitest"
|
|
68
|
+
}
|
|
69
|
+
}
|