@isi-ui7/bos7-shared 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.
Files changed (108) hide show
  1. package/dist/api.d.ts +4 -0
  2. package/dist/auth7/client.d.ts +26 -0
  3. package/dist/auth7/delegated-proxy.d.ts +33 -0
  4. package/dist/auth7/hooks/useAuth.d.ts +10 -0
  5. package/dist/auth7/hooks/useSession.d.ts +12 -0
  6. package/dist/auth7/index.d.ts +12 -0
  7. package/dist/auth7/provider.d.ts +13 -0
  8. package/dist/auth7/scope-guard.d.ts +5 -0
  9. package/dist/auth7/service-account.d.ts +2 -0
  10. package/dist/auth7/token-exchange.d.ts +11 -0
  11. package/dist/auth7/types.d.ts +63 -0
  12. package/dist/backend.d.ts +20 -0
  13. package/dist/crud-components.d.ts +47 -0
  14. package/dist/crud-hooks.d.ts +15 -0
  15. package/dist/crud-types.d.ts +86 -0
  16. package/dist/data-table/cursor-adapter.d.ts +74 -0
  17. package/dist/event-bus/index.d.ts +20 -0
  18. package/dist/event-bus/useEventBus.d.ts +21 -0
  19. package/dist/event-bus.d.ts +1 -0
  20. package/dist/export.d.ts +7 -0
  21. package/dist/form-layout.d.ts +39 -0
  22. package/dist/form-numeric.d.ts +35 -0
  23. package/dist/form-renderer-utils.d.ts +10 -0
  24. package/dist/form-renderer.d.ts +18 -0
  25. package/dist/form-rules.d.ts +12 -0
  26. package/dist/form-types.d.ts +130 -0
  27. package/dist/html2canvas.esm-d2sM-0Wm.js +4870 -0
  28. package/dist/i18n.d.ts +35 -0
  29. package/dist/index.d.ts +18 -0
  30. package/dist/index.es-Bylk5fh-.js +5649 -0
  31. package/dist/index.js +20779 -0
  32. package/dist/jspdf.es.min-Dzm5j8wC.js +9977 -0
  33. package/dist/jspdf.plugin.autotable-DcntYaes.js +1078 -0
  34. package/dist/modal-layout.d.ts +45 -0
  35. package/dist/nats/client.d.ts +54 -0
  36. package/dist/notif7.d.ts +21 -0
  37. package/dist/notification/routing.d.ts +4 -0
  38. package/dist/notifications-bff.d.ts +22 -0
  39. package/dist/observability/otel-init.d.ts +1 -0
  40. package/dist/page-layout.d.ts +24 -0
  41. package/dist/purify.es-CiEWEeUM.js +605 -0
  42. package/dist/shell/app-shell-layout.d.ts +70 -0
  43. package/dist/shell/branch.d.ts +6 -0
  44. package/dist/shell/interaction.d.ts +2 -0
  45. package/dist/shell/provider.d.ts +15 -0
  46. package/dist/style-contract.d.ts +72 -0
  47. package/dist/types/core.d.ts +39 -0
  48. package/dist/types/notification.d.ts +30 -0
  49. package/dist/workflow/api-client.d.ts +2 -0
  50. package/dist/workflow/backend.d.ts +9 -0
  51. package/dist/workflow/crud-client.d.ts +3 -0
  52. package/dist/workflow/notif7.d.ts +6 -0
  53. package/dist/workflow/starter.d.ts +44 -0
  54. package/dist/workflow/use-crud-form.d.ts +27 -0
  55. package/dist/workflow/use-workflow-tracker.d.ts +40 -0
  56. package/package.json +92 -0
  57. package/src/api.ts +40 -0
  58. package/src/auth7/client.ts +248 -0
  59. package/src/auth7/delegated-proxy.ts +80 -0
  60. package/src/auth7/hooks/useAuth.ts +21 -0
  61. package/src/auth7/hooks/useSession.ts +75 -0
  62. package/src/auth7/index.ts +14 -0
  63. package/src/auth7/provider.tsx +256 -0
  64. package/src/auth7/scope-guard.ts +54 -0
  65. package/src/auth7/service-account.ts +71 -0
  66. package/src/auth7/token-exchange.ts +125 -0
  67. package/src/auth7/types.ts +72 -0
  68. package/src/backend.ts +96 -0
  69. package/src/crud-components.tsx +580 -0
  70. package/src/crud-hooks.test.ts +32 -0
  71. package/src/crud-hooks.ts +31 -0
  72. package/src/crud-types.ts +64 -0
  73. package/src/data-table/cursor-adapter.ts +239 -0
  74. package/src/event-bus/index.ts +90 -0
  75. package/src/event-bus/useEventBus.ts +93 -0
  76. package/src/event-bus.ts +1 -0
  77. package/src/export.ts +62 -0
  78. package/src/form-contract.css +52 -0
  79. package/src/form-layout.tsx +90 -0
  80. package/src/form-numeric.ts +173 -0
  81. package/src/form-renderer-utils.ts +139 -0
  82. package/src/form-renderer.tsx +624 -0
  83. package/src/form-rules.ts +54 -0
  84. package/src/form-types.ts +126 -0
  85. package/src/i18n.tsx +92 -0
  86. package/src/index.ts +20 -0
  87. package/src/modal-layout.tsx +60 -0
  88. package/src/nats/client.ts +130 -0
  89. package/src/notif7.ts +76 -0
  90. package/src/notification/routing.ts +64 -0
  91. package/src/notifications-bff.ts +273 -0
  92. package/src/observability/otel-init.ts +41 -0
  93. package/src/page-layout.tsx +69 -0
  94. package/src/shell/app-shell-layout.tsx +494 -0
  95. package/src/shell/branch.ts +48 -0
  96. package/src/shell/interaction.ts +17 -0
  97. package/src/shell/provider.tsx +34 -0
  98. package/src/style-contract.test.ts +59 -0
  99. package/src/style-contract.ts +132 -0
  100. package/src/types/core.ts +46 -0
  101. package/src/types/notification.ts +35 -0
  102. package/src/workflow/api-client.ts +7 -0
  103. package/src/workflow/backend.ts +53 -0
  104. package/src/workflow/crud-client.ts +43 -0
  105. package/src/workflow/notif7.ts +28 -0
  106. package/src/workflow/starter.ts +153 -0
  107. package/src/workflow/use-crud-form.ts +176 -0
  108. package/src/workflow/use-workflow-tracker.ts +109 -0
@@ -0,0 +1,54 @@
1
+ import type { NextRequest } from 'next/server'
2
+ import { NextResponse } from 'next/server'
3
+
4
+ export type ScopeLevel = 'read' | 'write' | 'approve'
5
+
6
+ const SCOPE_PREFIX = 'bos7'
7
+
8
+ // scopeFromRequest infers the required scope from HTTP method.
9
+ // Routes matching */approve* always require 'approve'.
10
+ export function scopeFromRequest(req: NextRequest): ScopeLevel {
11
+ const path = req.nextUrl.pathname.toLowerCase()
12
+ if (path.includes('/approve')) return 'approve'
13
+ if (req.method === 'GET' || req.method === 'HEAD') return 'read'
14
+ return 'write'
15
+ }
16
+
17
+ // getTokenScopes decodes the access_token cookie (JWT, no verification needed —
18
+ // verification was done by auth7 upstream) and extracts the scope claim.
19
+ function getTokenScopes(req: NextRequest): string[] {
20
+ const raw = req.cookies.get('access_token')?.value
21
+ if (!raw) return []
22
+ try {
23
+ const payload = raw.split('.')[1]
24
+ if (!payload) return []
25
+ const decoded = JSON.parse(
26
+ Buffer.from(payload.replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf-8')
27
+ ) as { scope?: string }
28
+ return (decoded.scope ?? '').split(' ').filter(Boolean)
29
+ } catch {
30
+ return []
31
+ }
32
+ }
33
+
34
+ // requireScope returns a 403 NextResponse if the access_token does not carry
35
+ // the required scope, or null if the request may proceed.
36
+ export function requireScope(req: NextRequest, required: ScopeLevel): NextResponse | null {
37
+ const scopes = getTokenScopes(req)
38
+ const needed = `${SCOPE_PREFIX}:${required}`
39
+
40
+ // 'approve' implies 'write' and 'read'; 'write' implies 'read'.
41
+ const satisfies = (() => {
42
+ if (scopes.includes(`${SCOPE_PREFIX}:approve`)) return true
43
+ if (required === 'read' && scopes.includes(`${SCOPE_PREFIX}:write`)) return true
44
+ return scopes.includes(needed)
45
+ })()
46
+
47
+ if (!satisfies) {
48
+ return NextResponse.json(
49
+ { error: { code: 'INSUFFICIENT_SCOPE', message: `required scope: ${needed}` } },
50
+ { status: 403 },
51
+ )
52
+ }
53
+ return null
54
+ }
@@ -0,0 +1,71 @@
1
+ // lib/auth7/service-account.ts — BFF service account (client_credentials M2M)
2
+ // Server-side only. Obtains and caches M2M access tokens from auth7
3
+ // for service-to-service calls (replaces the old mintJWT pattern).
4
+ //
5
+ // Usage:
6
+ //
7
+ // const token = await getServiceToken();
8
+ // fetch('http://backend:8080/v1/data', {
9
+ // headers: { Authorization: `Bearer ${token}` },
10
+ // });
11
+
12
+ const AUTH7_CLIENT_SECRET = process.env.AUTH7_CLIENT_SECRET ?? '';
13
+
14
+ interface TokenCache {
15
+ token: string;
16
+ expiresAt: number; // epoch ms
17
+ }
18
+
19
+ let cache: TokenCache | null = null;
20
+
21
+ export async function getServiceToken(): Promise<string | null> {
22
+ const AUTH7_API_URL = process.env.AUTH7_API_URL;
23
+ const AUTH7_CLIENT_ID = process.env.AUTH7_CLIENT_ID;
24
+ if (!AUTH7_API_URL) throw new Error('[bos7] AUTH7_API_URL env var is required');
25
+ if (!AUTH7_CLIENT_ID) throw new Error('[bos7] AUTH7_CLIENT_ID env var is required');
26
+ if (!AUTH7_CLIENT_SECRET) return null;
27
+
28
+ const now = Date.now();
29
+ // Refresh when within 30s of expiry
30
+ if (cache && cache.expiresAt > now + 30_000) {
31
+ return cache.token;
32
+ }
33
+
34
+ try {
35
+ const body = new URLSearchParams({
36
+ grant_type: 'client_credentials',
37
+ ...((process.env.AUTH7_SERVICE_SCOPE ?? 'openid profile') ? { scope: process.env.AUTH7_SERVICE_SCOPE ?? 'openid profile' } : {}),
38
+ });
39
+
40
+ const basicAuth = Buffer.from(`${AUTH7_CLIENT_ID}:${AUTH7_CLIENT_SECRET}`).toString('base64');
41
+
42
+ const res = await fetch(`${AUTH7_API_URL}/oauth2/token`, {
43
+ method: 'POST',
44
+ headers: {
45
+ 'Content-Type': 'application/x-www-form-urlencoded',
46
+ Authorization: `Basic ${basicAuth}`,
47
+ },
48
+ body: body.toString(),
49
+ });
50
+
51
+ if (!res.ok) {
52
+ const err = await res.json().catch(() => ({}));
53
+ console.warn('[auth7:service-account] token request failed', res.status, err);
54
+ return null;
55
+ }
56
+
57
+ const data = await res.json() as { access_token: string; expires_in: number };
58
+ cache = {
59
+ token: data.access_token,
60
+ expiresAt: now + data.expires_in * 1000,
61
+ };
62
+ return cache.token;
63
+ } catch (err) {
64
+ console.warn('[auth7:service-account] token request error', err);
65
+ return null;
66
+ }
67
+ }
68
+
69
+ export function clearServiceTokenCache(): void {
70
+ cache = null;
71
+ }
@@ -0,0 +1,125 @@
1
+ // lib/auth7/token-exchange.ts — RFC 8693 token exchange for BFF
2
+ // Server-side only.
3
+
4
+ import { createHash } from 'node:crypto';
5
+
6
+ const AUTH7_API_URL = process.env.AUTH7_API_URL ?? 'http://localhost:8090';
7
+ const AUTH7_CLIENT_ID = process.env.AUTH7_CLIENT_ID ?? 'bos7-portal';
8
+ const AUTH7_CLIENT_SECRET = process.env.AUTH7_CLIENT_SECRET ?? '';
9
+
10
+ export interface TokenExchangeResult {
11
+ accessToken: string;
12
+ expiresIn: number;
13
+ scope: string;
14
+ }
15
+
16
+ export interface ExchangeOptions {
17
+ failClosed?: boolean;
18
+ }
19
+
20
+ // --- In-memory LRU cache ---
21
+
22
+ interface CacheEntry {
23
+ result: TokenExchangeResult;
24
+ expiresAt: number;
25
+ }
26
+
27
+ const MAX_CACHE_SIZE = 256;
28
+ const exchangeCache = new Map<string, CacheEntry>();
29
+
30
+ function cacheKey(userToken: string, audience: string): string {
31
+ return createHash('sha256').update(`${userToken}:${audience}`).digest('hex');
32
+ }
33
+
34
+ function pruneCache(): void {
35
+ const now = Date.now();
36
+ for (const [k, v] of exchangeCache) {
37
+ if (v.expiresAt <= now) exchangeCache.delete(k);
38
+ }
39
+ while (exchangeCache.size > MAX_CACHE_SIZE) {
40
+ const firstKey = exchangeCache.keys().next().value;
41
+ if (firstKey !== undefined) exchangeCache.delete(firstKey);
42
+ }
43
+ }
44
+
45
+ /** Clear the exchange cache. For use in tests. */
46
+ export function clearExchangeCache(): void {
47
+ exchangeCache.clear();
48
+ }
49
+
50
+ // --- Token exchange ---
51
+
52
+ export async function exchangeUserToken(
53
+ userToken: string,
54
+ audience: string,
55
+ scope?: string,
56
+ options: ExchangeOptions = {},
57
+ ): Promise<TokenExchangeResult> {
58
+ const { failClosed = false } = options;
59
+
60
+ const key = cacheKey(userToken, audience);
61
+ const cached = exchangeCache.get(key);
62
+ if (cached && cached.expiresAt > Date.now()) {
63
+ return cached.result;
64
+ }
65
+
66
+ if (!AUTH7_CLIENT_SECRET) {
67
+ if (failClosed) throw new Error('[auth7:token-exchange] AUTH7_CLIENT_SECRET not configured; fail-closed mode active');
68
+ return { accessToken: userToken, expiresIn: 900, scope: scope ?? '' };
69
+ }
70
+
71
+ const basicAuth = Buffer.from(`${AUTH7_CLIENT_ID}:${AUTH7_CLIENT_SECRET}`).toString('base64');
72
+
73
+ try {
74
+ const body = new URLSearchParams({
75
+ grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange',
76
+ subject_token: userToken,
77
+ subject_token_type: 'urn:ietf:params:oauth:token-type:access_token',
78
+ requested_token_type: 'urn:ietf:params:oauth:token-type:access_token',
79
+ audience,
80
+ });
81
+ if (scope) body.set('scope', scope);
82
+
83
+ const res = await fetch(`${AUTH7_API_URL}/oauth2/token`, {
84
+ method: 'POST',
85
+ headers: {
86
+ 'Content-Type': 'application/x-www-form-urlencoded',
87
+ Authorization: `Basic ${basicAuth}`,
88
+ },
89
+ body: body.toString(),
90
+ });
91
+
92
+ if (res.status === 400) {
93
+ const err = await res.json().catch(() => ({}));
94
+ if ((err as { error?: string }).error === 'unsupported_grant_type') {
95
+ console.warn('[auth7:token-exchange] not supported by server — using pass-through');
96
+ if (failClosed) throw new Error('[auth7:token-exchange] grant_type not implemented; fail-closed mode active');
97
+ return { accessToken: userToken, expiresIn: 900, scope: scope ?? '' };
98
+ }
99
+ console.warn('[auth7:token-exchange] failed', res.status, err);
100
+ if (failClosed) throw new Error(`[auth7:token-exchange] HTTP 400: ${JSON.stringify(err)}`);
101
+ return { accessToken: userToken, expiresIn: 900, scope: scope ?? '' };
102
+ }
103
+
104
+ if (!res.ok) {
105
+ console.warn('[auth7:token-exchange] failed', res.status);
106
+ if (failClosed) throw new Error(`[auth7:token-exchange] HTTP ${res.status} error; fail-closed mode active`);
107
+ return { accessToken: userToken, expiresIn: 900, scope: scope ?? '' };
108
+ }
109
+
110
+ const data = await res.json() as { access_token: string; expires_in: number; scope: string };
111
+ const result: TokenExchangeResult = {
112
+ accessToken: data.access_token,
113
+ expiresIn: data.expires_in,
114
+ scope: data.scope ?? scope ?? '',
115
+ };
116
+
117
+ if (exchangeCache.size >= MAX_CACHE_SIZE) pruneCache();
118
+ exchangeCache.set(key, { result, expiresAt: Date.now() + (data.expires_in - 30) * 1000 });
119
+ return result;
120
+ } catch (err) {
121
+ console.warn('[auth7:token-exchange] error', err);
122
+ if (failClosed) throw err instanceof Error ? err : new Error(`[auth7:token-exchange] unexpected error`);
123
+ return { accessToken: userToken, expiresIn: 900, scope: scope ?? '' };
124
+ }
125
+ }
@@ -0,0 +1,72 @@
1
+ // lib/auth7/types.ts — Auth7 types for frontend client
2
+
3
+ export interface TokenSet {
4
+ access_token: string;
5
+ token_type: 'Bearer';
6
+ expires_in: number;
7
+ refresh_token?: string;
8
+ scope: string;
9
+ id_token?: string;
10
+ }
11
+
12
+ export interface StoredTokenInfo {
13
+ access_token: string;
14
+ refresh_token?: string;
15
+ expires_at: number;
16
+ scope: string;
17
+ }
18
+
19
+ export interface UserProfile {
20
+ sub: string;
21
+ email?: string;
22
+ email_verified?: boolean;
23
+ name?: string;
24
+ given_name?: string;
25
+ family_name?: string;
26
+ picture?: string;
27
+ locale?: string;
28
+ tenant_id?: string;
29
+ roles?: string[];
30
+ branch_id?: string | null;
31
+ }
32
+
33
+ export interface AuthState {
34
+ isAuthenticated: boolean;
35
+ isLoading: boolean;
36
+ user: UserProfile | null;
37
+ accessToken: string | null;
38
+ error: string | null;
39
+ }
40
+
41
+ export interface SessionInfo {
42
+ user: UserProfile;
43
+ accessToken: string;
44
+ expiresAt: number;
45
+ scope: string;
46
+ }
47
+
48
+ export const OAUTH2_COOKIE_NAMES = {
49
+ ACCESS_TOKEN: 'access_token',
50
+ REFRESH_TOKEN: 'refresh_token',
51
+ ID_TOKEN: 'id_token',
52
+ TOKEN_EXPIRES_AT: 'token_expires_at',
53
+ TOKEN_SCOPE: 'token_scope',
54
+ PKCE_VERIFIER: 'auth7_pkce_verifier',
55
+ OAUTH_STATE: 'auth7_oauth_state',
56
+ OAUTH_RETURN_URL: 'auth7_return_url',
57
+ } as const;
58
+
59
+ export const OAUTH2_ENDPOINTS = {
60
+ AUTHORIZE: '/oauth2/authorize',
61
+ TOKEN: '/oauth2/token',
62
+ REVOKE: '/oauth2/revoke',
63
+ LOGOUT: '/oauth2/logout',
64
+ USERINFO: '/oauth2/userinfo',
65
+ } as const;
66
+
67
+ export const AUTH7_SCOPES = {
68
+ OPENID: 'openid',
69
+ PROFILE: 'profile',
70
+ EMAIL: 'email',
71
+ ROLES: 'roles',
72
+ } as const;
package/src/backend.ts ADDED
@@ -0,0 +1,96 @@
1
+ // lib/backend.ts — server-side HTTP client untuk service7 backend
2
+ // Hanya digunakan dari API routes (server-side). Tidak pernah diimport dari client components.
3
+ //
4
+ // Migration (Plan 11):
5
+ // - Untuk forwarding user identity ke backend: kirim accessToken
6
+ // - Untuk M2M service account: gunakan getServiceToken() dari lib/auth7/service-account
7
+ // - Fallback: legacy mintJWT dengan BACKEND_JWT_SECRET (untuk environment tanpa auth7)
8
+
9
+ import { SignJWT } from 'jose';
10
+
11
+ function getBackendUrl(): string {
12
+ const backendUrl = process.env.BACKEND_URL;
13
+ if (!backendUrl) throw new Error('[bos7] BACKEND_URL env var is required');
14
+ return backendUrl;
15
+ }
16
+
17
+ function getJwtSecret(): string {
18
+ return process.env.BACKEND_JWT_SECRET ?? '';
19
+ }
20
+
21
+ const SENTINEL_UUID = '00000000-0000-0000-0000-000000000001';
22
+
23
+ async function mintJWT(): Promise<string> {
24
+ const jwtSecret = getJwtSecret();
25
+ if (!jwtSecret) {
26
+ throw new Error('[bos7] BACKEND_JWT_SECRET env var is required when accessToken is not provided');
27
+ }
28
+ const secret = new TextEncoder().encode(jwtSecret);
29
+ return new SignJWT({
30
+ branch_id: SENTINEL_UUID,
31
+ roles: ['service'],
32
+ })
33
+ .setProtectedHeader({ alg: 'HS256' })
34
+ .setSubject(SENTINEL_UUID)
35
+ .setJti(crypto.randomUUID())
36
+ .setExpirationTime('60s')
37
+ .setIssuedAt()
38
+ .sign(secret);
39
+ }
40
+
41
+ async function request<T>(
42
+ method: 'GET' | 'POST' | 'PUT' | 'DELETE',
43
+ path: string,
44
+ options: { query?: Record<string, string>; body?: unknown; accessToken?: string } = {}
45
+ ): Promise<{ ok: boolean; data?: T; error?: string }> {
46
+ // Prefer real auth7 token (user or M2M), fall back to legacy minted JWT.
47
+ const token = options.accessToken ?? (await mintJWT());
48
+ const backendUrl = getBackendUrl();
49
+
50
+ let url = `${backendUrl}${path}`;
51
+ if (options.query && Object.keys(options.query).length > 0) {
52
+ url += '?' + new URLSearchParams(options.query).toString();
53
+ }
54
+
55
+ const res = await fetch(url, {
56
+ method,
57
+ headers: {
58
+ 'Content-Type': 'application/json',
59
+ Authorization: `Bearer ${token}`,
60
+ },
61
+ ...(options.body !== undefined ? { body: JSON.stringify(options.body) } : {}),
62
+ });
63
+
64
+ if (!res.ok) {
65
+ try {
66
+ const errBody = await res.json() as { error?: { message?: string; code?: string } };
67
+ const msg = errBody?.error?.message ?? errBody?.error?.code ?? `backend ${method} ${path} failed: ${res.status}`;
68
+ return { ok: false, error: msg };
69
+ } catch {
70
+ return { ok: false, error: `backend ${method} ${path} failed: ${res.status}` };
71
+ }
72
+ }
73
+
74
+ if (res.status === 204 || res.headers.get('content-length') === '0') {
75
+ return { ok: true };
76
+ }
77
+
78
+ const data = (await res.json()) as T;
79
+ return { ok: true, data };
80
+ }
81
+
82
+ export function backendGet<T>(path: string, query?: Record<string, string>, accessToken?: string) {
83
+ return request<T>('GET', path, { query, accessToken });
84
+ }
85
+
86
+ export function backendPost<T>(path: string, body: unknown, accessToken?: string) {
87
+ return request<T>('POST', path, { body, accessToken });
88
+ }
89
+
90
+ export function backendPut<T>(path: string, body: unknown, accessToken?: string) {
91
+ return request<T>('PUT', path, { body, accessToken });
92
+ }
93
+
94
+ export function backendDelete<T>(path: string, accessToken?: string) {
95
+ return request<T>('DELETE', path, { accessToken });
96
+ }