@alliance-droid/status-feedback-system 4.0.2 → 4.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -163,6 +163,7 @@ The board is composed from parts around **one shared state** (`createFeedbackBoa
163
163
  <script lang="ts">
164
164
  import {
165
165
  createFeedbackBoard,
166
+ createAuthTokenGetter,
166
167
  FeedbackSidebar,
167
168
  FeedbackList,
168
169
  FeedbackDetail,
@@ -175,7 +176,7 @@ The board is composed from parts around **one shared state** (`createFeedbackBoa
175
176
  apiUrl: 'https://status-admin.example.com',
176
177
  apiKey: 'sf_abc123...',
177
178
  userEmail: data.user?.email,
178
- getAuthToken: () => auth.getAccessToken(), // optional — enables status management
179
+ getAuthToken: createAuthTokenGetter(), // optional — enables status management
179
180
  categoryConfig: {
180
181
  Bug: { icon: 'fa-solid fa-bug', label: 'Bugs' },
181
182
  'Feature Request': { icon: 'fa-solid fa-lightbulb', label: 'Ideas' },
@@ -213,6 +214,7 @@ Categories are fixed by the system (**Bug**, **Feature Request**) — `categoryC
213
214
  When you pass `getAuthToken`, a signed-in **Admin** or **ProductOwner** can change an item's status from the board (via the status badge → menu) — Open → Considering → Accepted, or a decline with a reason — no trip to the admin dashboard.
214
215
 
215
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.
217
+ - **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();`
216
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 a role may make, including reason prompts on declines/reopens) is enforced **server-side** by the same logic as the dashboard — the client never decides permissions.
217
219
  - The available transitions are returned by the backend per item, so the menu always reflects exactly what that user may do.
218
220
 
@@ -299,9 +301,13 @@ import {
299
301
  // Feedback board (composable)
300
302
  import {
301
303
  createFeedbackBoard,
304
+ createAuthTokenGetter,
302
305
  FeedbackSidebar, FeedbackList, FeedbackItem, FeedbackDetail, FeedbackSubmitForm,
303
306
  } from '@alliance-droid/status-feedback-system';
304
- import type { FeedbackBoardState, FeedbackBoardOptions, CategoryConfig } from '@alliance-droid/status-feedback-system';
307
+ import type {
308
+ FeedbackBoardState, FeedbackBoardOptions, CategoryConfig,
309
+ AuthTokenGetter, AuthTokenGetterOptions,
310
+ } from '@alliance-droid/status-feedback-system';
305
311
 
306
312
  // API Client
307
313
  import { createStatusClient } from '@alliance-droid/status-feedback-system';
package/dist/api.d.ts CHANGED
@@ -70,6 +70,8 @@ export interface BoardTransitions {
70
70
  publicStatus: PublicStatus;
71
71
  };
72
72
  targets: BoardTransition[];
73
+ /** Whether this user may manage status on this project (drives the UI affordance). */
74
+ canManage: boolean;
73
75
  }
74
76
  /** Result of applying a status change from the board. */
75
77
  export interface BoardStatusUpdate {
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Client-side accessor for the bearer token the board's status controls send to
3
+ * the `status-feedback-admin` backend — the canonical `getAuthToken` for
4
+ * `createFeedbackBoard`.
5
+ *
6
+ * The backend verifies the token against the IdP and requires its audience to
7
+ * cover `status-feedback-admin`, so it must be an **access token** scoped to that
8
+ * resource (an id token, whose `aud` is the app's client id, is rejected).
9
+ *
10
+ * A stateless OIDC relying party never exposes the long-lived refresh token to
11
+ * the browser, so a same-origin host endpoint mints a fresh, short-lived token
12
+ * server-side and returns only that. With `@alliance-droid/svelte-auth-core`,
13
+ * enabling `enableAccessTokenEndpoint` serves exactly this at
14
+ * `GET /auth/access-token` (returns `{ accessToken, expiresAt }`).
15
+ *
16
+ * Usage:
17
+ * // one module-level instance per app (shares the cache)
18
+ * export const getAuthToken = createAuthTokenGetter();
19
+ * // → createFeedbackBoard({ ..., getAuthToken })
20
+ */
21
+ export interface AuthTokenGetterOptions {
22
+ /** Endpoint that mints a fresh token. Default: `/auth/access-token`. */
23
+ endpoint?: string;
24
+ /** Refresh this many ms before the token's `expiresAt`. Default: 30000. */
25
+ expirySkewMs?: number;
26
+ /** Custom fetch (e.g. for tests). Default: `globalThis.fetch`. */
27
+ fetch?: typeof globalThis.fetch;
28
+ }
29
+ /** A `getAuthToken` function with a `clear()` to drop the cache (e.g. after logout). */
30
+ export interface AuthTokenGetter {
31
+ (): Promise<string | null>;
32
+ clear(): void;
33
+ }
34
+ /**
35
+ * Create a cached, single-flight token accessor.
36
+ *
37
+ * Safe to call repeatedly: an in-flight request is shared, and a still-valid
38
+ * token is served from memory without a network round-trip. Resolves `null` when
39
+ * the user isn't signed in / not privileged (any non-OK response) or on SSR —
40
+ * which the board reads as "no manage-status control".
41
+ */
42
+ export declare function createAuthTokenGetter(options?: AuthTokenGetterOptions): AuthTokenGetter;
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Client-side accessor for the bearer token the board's status controls send to
3
+ * the `status-feedback-admin` backend — the canonical `getAuthToken` for
4
+ * `createFeedbackBoard`.
5
+ *
6
+ * The backend verifies the token against the IdP and requires its audience to
7
+ * cover `status-feedback-admin`, so it must be an **access token** scoped to that
8
+ * resource (an id token, whose `aud` is the app's client id, is rejected).
9
+ *
10
+ * A stateless OIDC relying party never exposes the long-lived refresh token to
11
+ * the browser, so a same-origin host endpoint mints a fresh, short-lived token
12
+ * server-side and returns only that. With `@alliance-droid/svelte-auth-core`,
13
+ * enabling `enableAccessTokenEndpoint` serves exactly this at
14
+ * `GET /auth/access-token` (returns `{ accessToken, expiresAt }`).
15
+ *
16
+ * Usage:
17
+ * // one module-level instance per app (shares the cache)
18
+ * export const getAuthToken = createAuthTokenGetter();
19
+ * // → createFeedbackBoard({ ..., getAuthToken })
20
+ */
21
+ /**
22
+ * Create a cached, single-flight token accessor.
23
+ *
24
+ * Safe to call repeatedly: an in-flight request is shared, and a still-valid
25
+ * token is served from memory without a network round-trip. Resolves `null` when
26
+ * the user isn't signed in / not privileged (any non-OK response) or on SSR —
27
+ * which the board reads as "no manage-status control".
28
+ */
29
+ export function createAuthTokenGetter(options = {}) {
30
+ const endpoint = options.endpoint ?? '/auth/access-token';
31
+ const skewMs = options.expirySkewMs ?? 30_000;
32
+ const fetchFn = options.fetch ?? ((...args) => globalThis.fetch(...args));
33
+ let cached = null;
34
+ let inflight = null;
35
+ const getToken = (async () => {
36
+ // SSR has no session cookies to send and no relative-fetch origin; the board
37
+ // only calls this on client interaction anyway.
38
+ if (typeof window === 'undefined')
39
+ return null;
40
+ if (cached && Date.now() < cached.expiresAt - skewMs)
41
+ return cached.token;
42
+ if (inflight)
43
+ return inflight;
44
+ inflight = (async () => {
45
+ try {
46
+ const res = await fetchFn(endpoint, {
47
+ headers: { accept: 'application/json' },
48
+ credentials: 'same-origin',
49
+ });
50
+ if (!res.ok) {
51
+ cached = null;
52
+ return null;
53
+ }
54
+ const data = (await res.json());
55
+ const token = data.accessToken ?? data.idToken ?? data.token ?? null;
56
+ if (!token) {
57
+ cached = null;
58
+ return null;
59
+ }
60
+ cached = { token, expiresAt: normalizeExpiry(data.expiresAt) };
61
+ return token;
62
+ }
63
+ catch {
64
+ // Network/parse failure → treat as no token; the control simply won't show.
65
+ cached = null;
66
+ return null;
67
+ }
68
+ finally {
69
+ inflight = null;
70
+ }
71
+ })();
72
+ return inflight;
73
+ });
74
+ getToken.clear = () => {
75
+ cached = null;
76
+ };
77
+ return getToken;
78
+ }
79
+ /**
80
+ * Coerce the endpoint's `expiresAt` to epoch ms. Accepts ms or seconds; anything
81
+ * missing/invalid becomes 0 ("already expired") so a token we can't reason about
82
+ * is never cached.
83
+ */
84
+ function normalizeExpiry(expiresAt) {
85
+ if (typeof expiresAt !== 'number' || !Number.isFinite(expiresAt))
86
+ return 0;
87
+ // Epoch seconds are ~1.7e9; epoch ms are ~1.7e12. Treat 10-digit values as seconds.
88
+ return expiresAt < 1e12 ? expiresAt * 1000 : expiresAt;
89
+ }
@@ -184,11 +184,13 @@ export function createFeedbackBoard(options) {
184
184
  try {
185
185
  const result = await client.getBoardTransitions(itemId, token);
186
186
  transitions = { ...transitions, [itemId]: result };
187
- statusPrivilege = true; // a successful load means the backend authorized this user
187
+ // The backend tells us whether this user may manage status (200 either
188
+ // way — no 403 console noise for ordinary signed-in users).
189
+ statusPrivilege = result.canManage;
188
190
  return result;
189
191
  }
190
192
  catch {
191
- // 401/403 → not signed in or not privileged for this item: no control.
193
+ // 401 (no/invalid token) or network error → no control.
192
194
  return null;
193
195
  }
194
196
  }
@@ -205,7 +207,8 @@ export function createFeedbackBoard(options) {
205
207
  if (!sample)
206
208
  return;
207
209
  privilegeProbed = true;
208
- statusPrivilege = (await loadTransitions(sample.id)) !== null;
210
+ // loadTransitions sets statusPrivilege from the result's canManage flag.
211
+ await loadTransitions(sample.id);
209
212
  }
210
213
  async function setStatus(itemId, status, reason) {
211
214
  const token = await resolveToken();
package/dist/index.d.ts CHANGED
@@ -9,6 +9,8 @@
9
9
  */
10
10
  export type { Project, System, Incident, IncidentUpdate, Feedback, FeedbackResponse, FeedbackVisibility, StatusHistoryEntry, SystemStatus, IncidentStatus, IncidentSeverity, FeedbackStatus, ApiResponse, } from './types.js';
11
11
  export { deriveOverallStatus } from './utils.js';
12
+ export { createAuthTokenGetter } from './auth-token.js';
13
+ export type { AuthTokenGetter, AuthTokenGetterOptions } from './auth-token.js';
12
14
  export { createStatusClient } from './api.js';
13
15
  export type { StatusClient, StatusClientConfig, StatusData, IncidentData, UptimeData, BoardData, BoardItem, BoardCategory } from './api.js';
14
16
  export { default as StatusPage } from './components/StatusPage.svelte';
package/dist/index.js CHANGED
@@ -9,6 +9,8 @@
9
9
  */
10
10
  // ── Utilities ──
11
11
  export { deriveOverallStatus } from './utils.js';
12
+ // ── Auth token (getAuthToken for the board's status controls) ──
13
+ export { createAuthTokenGetter } from './auth-token.js';
12
14
  // ── API Client ──
13
15
  export { createStatusClient } from './api.js';
14
16
  // ── Client Components ──
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alliance-droid/status-feedback-system",
3
- "version": "4.0.2",
3
+ "version": "4.1.0",
4
4
  "type": "module",
5
5
  "description": "Drop-in system status pages and user feedback forms for SvelteKit apps",
6
6
  "svelte": "./dist/index.js",