@rsmlamongan/user-menu 0.0.0-stage → 0.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.
@@ -0,0 +1,4 @@
1
+ import { type Plugin } from "vue";
2
+ import type { UserMenuOptions } from "./types.js";
3
+ export declare function createUserMenu(options: UserMenuOptions): Plugin;
4
+ export declare function injectMenuOptions(): UserMenuOptions;
@@ -0,0 +1,56 @@
1
+ import type { Favorite, UserMenuCreateHandler, UserMenuOptions, UserMenuSaveHandler } from "./types.js";
2
+ export declare function createMenuState(options: UserMenuOptions, onVisibilityChange?: (open: boolean, sessionReset?: boolean) => void, source?: {
3
+ getItems: () => readonly Favorite[] | undefined;
4
+ onItemsChange: (items: Favorite[]) => void;
5
+ getCreate?: () => UserMenuCreateHandler | undefined;
6
+ getSave?: () => UserMenuSaveHandler | undefined;
7
+ }): {
8
+ state: {
9
+ open: boolean;
10
+ loading: boolean;
11
+ loaded: boolean;
12
+ busy: boolean;
13
+ favorites: {
14
+ id: string;
15
+ title: string;
16
+ url: string;
17
+ appId: string;
18
+ }[];
19
+ error: string;
20
+ notice: string;
21
+ editing: boolean;
22
+ draft: {
23
+ id: string;
24
+ title: string;
25
+ url: string;
26
+ appId: string;
27
+ }[];
28
+ adding: boolean;
29
+ addTitle: string;
30
+ addUrl: string;
31
+ addError: string;
32
+ confirmDiscard: boolean;
33
+ };
34
+ session: import("vue").ComputedRef<import("./types.js").UserMenuSession | null>;
35
+ dirty: import("vue").ComputedRef<boolean>;
36
+ canLoad: import("vue").ComputedRef<boolean>;
37
+ canCreate: import("vue").ComputedRef<boolean>;
38
+ canSave: import("vue").ComputedRef<boolean>;
39
+ options: UserMenuOptions;
40
+ requestVisibility: (open: boolean) => boolean;
41
+ load: () => Promise<void>;
42
+ startEdit: () => void;
43
+ cancelEdit: () => void;
44
+ move: (id: string, target: number) => void;
45
+ remove: (id: string) => void;
46
+ save: () => Promise<void>;
47
+ startAdd: () => void;
48
+ add: () => Promise<void>;
49
+ cancelAdd: () => void;
50
+ requestCloseAdd: () => void;
51
+ discard: () => void;
52
+ openSettings: () => Promise<void>;
53
+ restoreFocus: () => void;
54
+ dispose: () => void;
55
+ };
56
+ export type MenuState = ReturnType<typeof createMenuState>;
package/dist/style.css ADDED
@@ -0,0 +1,2 @@
1
+ .rsml-user-menu{max-width:100vw;font-family:inherit}.rsml-user-menu h2{font-size:1.125rem;font-weight:600;line-height:1.5}.rsml-user-menu__panel{flex-direction:column;min-height:100%;display:flex}.rsml-user-menu__body,.rsml-user-menu__slot-header,.rsml-user-menu__slot-footer{padding:16px}.rsml-user-menu__slot-footer{border-color:rgba(var(--v-border-color), var(--v-border-opacity));border-style:solid;border-width:1px 0 0}.rsml-user-menu__heading-row,.rsml-user-menu__heading-actions,.rsml-user-menu__edit-title,.rsml-user-menu__item-actions,.rsml-user-menu__actions{align-items:center;gap:8px;display:flex}.rsml-user-menu__heading-row{justify-content:space-between}.rsml-user-menu__heading-actions{flex-shrink:0}.rsml-user-menu__text{overflow-wrap:anywhere;min-width:0}.rsml-user-menu__muted,.rsml-user-menu__meta,.rsml-user-menu__hint{color:rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));font-size:.875rem}.rsml-user-menu__hint{margin:8px 0 20px}.rsml-user-menu__meta{margin-top:4px;display:block}.rsml-user-menu__list{margin:12px 0;padding:0;list-style:none}.rsml-user-menu__list li+li{margin-top:8px}.rsml-user-menu__link,.rsml-user-menu__edit-item{border:1px solid rgba(var(--v-border-color), var(--v-border-opacity));overflow-wrap:anywhere;border-radius:8px;padding:12px;display:block}.rsml-user-menu__link{color:inherit;text-decoration:none}.rsml-user-menu__link:hover,.rsml-user-menu__control:hover:not(:disabled){background:rgba(var(--v-theme-on-surface), var(--v-hover-opacity))}.rsml-user-menu__control{cursor:pointer;min-width:44px;min-height:44px;color:inherit;font:inherit;background:0 0;border:0;border-radius:6px;flex-shrink:0;justify-content:center;align-items:center;font-size:1.25rem;display:inline-flex}.rsml-user-menu__control:disabled{cursor:default;opacity:var(--v-disabled-opacity)}.rsml-user-menu__drag{cursor:grab}.rsml-user-menu__item-actions{justify-content:flex-end;margin-top:4px}.rsml-user-menu__actions{justify-content:flex-end}.rsml-user-menu__link:focus-visible,.rsml-user-menu__control:focus-visible{outline:2px solid rgb(var(--v-theme-primary));outline-offset:2px}.rsml-user-menu__empty{text-align:center;padding:24px 4px}.rsml-user-menu__empty p+p{margin-top:8px}.rsml-user-menu__alert{margin:12px 0}.rsml-user-menu__status{margin:12px 0;font-size:.875rem}.rsml-user-menu__status:empty{margin:0}.rsml-user-menu__url{overflow-wrap:anywhere;margin-top:4px}.rsml-user-menu__dialog .v-card-actions{flex-wrap:wrap}
2
+ /*$vite$:1*/
@@ -0,0 +1,52 @@
1
+ export interface Favorite {
2
+ id: string;
3
+ title: string;
4
+ url: string;
5
+ appId: string;
6
+ }
7
+ export interface CreateFavoriteInput {
8
+ title: string;
9
+ url: string;
10
+ appId: string;
11
+ }
12
+ export interface SaveFavoriteChangesInput {
13
+ orderedIds: string[];
14
+ deletedIds: string[];
15
+ }
16
+ export interface UserMenuApi {
17
+ listFavorites(): Promise<Favorite[]>;
18
+ createFavorite(input: CreateFavoriteInput): Promise<Favorite>;
19
+ saveFavoriteChanges(input: SaveFavoriteChangesInput): Promise<Favorite[]>;
20
+ }
21
+ export type UserMenuCreateHandler = (input: CreateFavoriteInput) => Promise<Favorite>;
22
+ export type UserMenuSaveHandler = (input: SaveFavoriteChangesInput) => Promise<Favorite[]>;
23
+ export interface UserMenuUser {
24
+ id: string;
25
+ name: string;
26
+ avatarUrl?: string;
27
+ }
28
+ export interface UserMenuSession {
29
+ /** Identitas siklus login, bukan token autentikasi. */
30
+ key: string;
31
+ user: UserMenuUser;
32
+ }
33
+ export interface CurrentPage {
34
+ title: string;
35
+ url: string;
36
+ }
37
+ export type UserMenuSettings = {
38
+ label: string;
39
+ url: string;
40
+ onClick?: never;
41
+ } | {
42
+ label: string;
43
+ onClick: () => void | Promise<void>;
44
+ url?: never;
45
+ };
46
+ export interface UserMenuOptions {
47
+ appId: string;
48
+ getSession: () => UserMenuSession | null;
49
+ getCurrentPage: () => CurrentPage;
50
+ api?: UserMenuApi;
51
+ settings?: UserMenuSettings;
52
+ }
@@ -0,0 +1,49 @@
1
+ //#region src/errors.ts
2
+ var e = class extends Error {
3
+ code;
4
+ constructor(e) {
5
+ super(e), this.code = e, this.name = "UserMenuError";
6
+ }
7
+ };
8
+ function t(t, n) {
9
+ if (!(t instanceof e)) return n;
10
+ switch (t.code) {
11
+ case "unauthenticated": return "Sesi tidak valid. Masuk kembali melalui aplikasi utama.";
12
+ case "invalid-input": return "Periksa judul dan URL halaman. Gunakan URL HTTP atau HTTPS yang lengkap.";
13
+ case "duplicate": return "Halaman ini sudah ada di favorit.";
14
+ case "conflict": return "Daftar favorit telah berubah. Batalkan edit dan muat ulang daftar sebelum mencoba lagi.";
15
+ default: return n;
16
+ }
17
+ }
18
+ //#endregion
19
+ //#region src/validation.ts
20
+ function n(t) {
21
+ if (typeof t != "string" || !t.trim()) throw new e("invalid-input");
22
+ return t.trim();
23
+ }
24
+ function r(t) {
25
+ try {
26
+ let r = new URL(n(t));
27
+ if (!["http:", "https:"].includes(r.protocol) || r.username || r.password) throw new e("invalid-input");
28
+ return r.href;
29
+ } catch {
30
+ throw new e("invalid-input");
31
+ }
32
+ }
33
+ function i(t) {
34
+ if (!t || typeof t != "object") throw new e("invalid-input");
35
+ return {
36
+ id: n(t.id),
37
+ title: n(t.title),
38
+ url: r(t.url),
39
+ appId: n(t.appId)
40
+ };
41
+ }
42
+ function a(t) {
43
+ if (!Array.isArray(t)) throw new e("invalid-input");
44
+ let n = t.map(i);
45
+ if (new Set(n.map((e) => e.id)).size !== n.length || new Set(n.map((e) => e.url)).size !== n.length) throw new e("invalid-input");
46
+ return n;
47
+ }
48
+ //#endregion
49
+ export { e as a, r as i, a as n, t as o, n as r, i as t };
@@ -0,0 +1,5 @@
1
+ import type { Favorite } from "./types.js";
2
+ export declare function nonEmpty(value: unknown): string;
3
+ export declare function normalizeUrl(value: unknown): string;
4
+ export declare function copyFavorite(value: Favorite): Favorite;
5
+ export declare function copyFavorites(values: readonly Favorite[]): Favorite[];
@@ -0,0 +1,221 @@
1
+ # Kontrak API RSML User Menu
2
+
3
+ Kontrak ini mendefinisikan data dan operasi asynchronous frontend, mock development, serta adapter HTTP untuk User Menu pada kepegawaian-api. Keduanya memenuhi interface yang sama tanpa mengubah drawer.
4
+
5
+ **Status: implementasi backend dan adapter HTTP tersedia di source.** Pemasangan schema, deployment, penerimaan token produksi, dan integrasi aplikasi lintas origin perlu diverifikasi pada environment tujuan. Nama metode TypeScript dipetakan ke endpoint pada bagian adapter HTTP.
6
+
7
+ Interface tersedia dari entry utama. Adapter development tersedia pada `@rsmlamongan/user-menu/mock`; adapter HTTP pada `@rsmlamongan/user-menu/http`.
8
+
9
+ [Kembali ke README](../README.md) | [Panduan development](DEVELOPMENT.md)
10
+
11
+ ## Model data dan urutan
12
+
13
+ ```ts
14
+ interface Favorite {
15
+ id: string;
16
+ title: string;
17
+ url: string;
18
+ appId: string;
19
+ }
20
+
21
+ interface CreateFavoriteInput {
22
+ title: string;
23
+ url: string;
24
+ appId: string;
25
+ }
26
+
27
+ interface SaveFavoriteChangesInput {
28
+ orderedIds: string[];
29
+ deletedIds: string[];
30
+ }
31
+ ```
32
+
33
+ | Field | Makna |
34
+ | ------- | -------------------------------------------------------------------------------------------- |
35
+ | `id` | Identitas stabil favorit dari penyimpanan, bukan indeks tampilan. |
36
+ | `title` | Judul yang ditampilkan kepada pengguna. |
37
+ | `url` | URL HTTP(S) absolut tujuan shortcut, termasuk base path/route yang dibutuhkan aplikasi asal. |
38
+ | `appId` | Identitas aplikasi asal favorit; bukan pembatas kepemilikan daftar. |
39
+
40
+ Pemilik data tidak diambil dari `userId` pada input frontend. Pada API asli, pemilik ditentukan oleh sesi login yang diverifikasi server. `appId` adalah metadata asal dan tidak memberikan hak akses.
41
+
42
+ Daftar favorit berlaku lintas aplikasi untuk pengguna yang sama. `listFavorites()` mengembalikan seluruh favorit pengguna tersebut, bukan hanya favorit dengan `appId` aplikasi yang sedang dibuka.
43
+
44
+ Aplikasi dapat menyediakan daftar lengkap yang sama melalui prop `items` pada `UserMenuHost`. Dalam mode itu host tidak memanggil `listFavorites()`. Tambah dan Simpan memakai callback `@create`/`@save` jika diberikan, atau adapter di bawah. Setelah mutasi berhasil, event `update:items` mengembalikan salinan daftar lengkap kepada parent. Props/callback bukan kontrak transport baru dan tidak mengubah tiga operasi adapter; lihat [sumber daftar eksternal](DEVELOPMENT.md#sumber-daftar-melalui-prop-items) dan [callback parent](DEVELOPMENT.md#callback-create-dan-save).
45
+
46
+ Urutan array adalah urutan tampilan dan penyimpanan yang otoritatif pada batas adapter. Frontend tidak mengurutkan ulang berdasarkan judul atau aplikasi asal. Field `position` tidak diperlukan pada model minimal; backend bebas memilih representasi urutan internal selama hasil adapter konsisten.
47
+
48
+ ## Kontrak adapter asynchronous
49
+
50
+ ```ts
51
+ interface UserMenuApi {
52
+ listFavorites(): Promise<Favorite[]>;
53
+
54
+ createFavorite(input: CreateFavoriteInput): Promise<Favorite>;
55
+
56
+ saveFavoriteChanges(input: SaveFavoriteChangesInput): Promise<Favorite[]>;
57
+ }
58
+ ```
59
+
60
+ Tipe ini diekspor dari entry utama dan dihasilkan saat build. Transport dan autentikasi bukan bagian drawer. Adapter HTTP memakai callback client aplikasi utama; aplikasi menangani token/refresh dan adapter membuka envelope menjadi hasil di atas.
61
+
62
+ | Operasi | Hasil sukses | Dampak pada urutan |
63
+ | ---------------------------- | ------------------------------------------------- | ---------------------------------------------------------- |
64
+ | `listFavorites()` | Seluruh favorit pengguna sesuai urutan tersimpan. | Tidak mengubah data. |
65
+ | `createFavorite(input)` | Favorit baru dengan `id` stabil. | Menambahkan favorit ke urutan terakhir. |
66
+ | `saveFavoriteChanges(input)` | Seluruh favorit yang tersisa sesuai urutan akhir. | Menghapus dan menerapkan urutan dalam satu operasi atomik. |
67
+
68
+ Mode edit MVP berfokus pada hapus serta pengurutan. Pengubahan judul favorit yang sudah tersimpan, pengubahan URL, dan formulir akun tidak tersedia dalam kontrak ini.
69
+
70
+ Semua kegagalan disampaikan melalui Promise yang reject, bukan diubah menjadi `[]` atau hasil sukses palsu. Pemetaan error HTTP dan envelope dijelaskan pada bagian adapter HTTP.
71
+
72
+ `UserMenuOptions.api` opsional. Dengan daftar `items` serta callback create/save, aplikasi tidak perlu menyediakan adapter pada plugin. Callback memakai input dan hasil yang sama dengan operasi adapter terkait, termasuk `Promise<Favorite>` untuk create dan `Promise<Favorite[]>` untuk save. Override berlaku per operasi; kegagalan callback tidak mencoba adapter sebagai fallback. Jika adapter tetap diberikan, kontrak tiga operasi `UserMenuApi` lengkap di atas tetap berlaku.
73
+
74
+ ### Error adapter
75
+
76
+ Entry utama mengekspor `UserMenuError` dan tipe `UserMenuErrorCode`:
77
+
78
+ ```ts
79
+ import { UserMenuError } from "@rsmlamongan/user-menu";
80
+
81
+ throw new UserMenuError("conflict");
82
+ ```
83
+
84
+ | Kode | Makna |
85
+ | ----------------- | ------------------------------------------------------------ |
86
+ | `unauthenticated` | Sesi tidak tersedia atau ditolak. |
87
+ | `invalid-input` | Judul kosong atau bentuk input/URL tidak valid. |
88
+ | `duplicate` | URL yang dinormalisasi sudah menjadi favorit. |
89
+ | `conflict` | ID atau keseluruhan daftar tidak cocok dengan state terbaru. |
90
+ | `unavailable` | Layanan atau penyimpanan tidak tersedia. |
91
+
92
+ UI menampilkan pesan tetap berdasarkan kode dan pesan umum untuk error biasa; detail error mentah tidak ditampilkan. Adapter HTTP memetakan status transport dan kode logis backend ke kode TypeScript ini.
93
+
94
+ Judul dan appId dipangkas pada kedua ujungnya. URL harus absolut HTTP(S), tanpa username/password; parser `URL` menormalkan host/port/path dengan query dan hash tetap dipertahankan. Backend juga memvalidasi dan menormalkan input. Duplikasi dibandingkan secara ordinal setelah normalisasi, sehingga path/query/hash berbeda tetap berbeda.
95
+
96
+ ## Penyimpanan perubahan mode edit
97
+
98
+ Misalnya daftar awal yang sudah dimuat berurutan sebagai berikut:
99
+
100
+ ```text
101
+ favorite-1, favorite-2, favorite-3
102
+ ```
103
+
104
+ Pengguna menghapus `favorite-2` dan memindahkan `favorite-3` ke urutan pertama. Permintaan adapter:
105
+
106
+ ```ts
107
+ const favorites = await api.saveFavoriteChanges({
108
+ orderedIds: ["favorite-3", "favorite-1"],
109
+ deletedIds: ["favorite-2"],
110
+ });
111
+ ```
112
+
113
+ `favorites` berisi data lengkap dengan urutan `favorite-3`, lalu `favorite-1`.
114
+
115
+ Aturan kontraknya:
116
+
117
+ - `orderedIds` berisi seluruh ID yang dipertahankan, sesuai urutan akhirnya. `deletedIds` berisi ID yang dihapus dari daftar yang diedit.
118
+ - Setiap ID hanya muncul sekali dan tidak boleh berada di kedua array.
119
+ - Gabungan kedua array harus sama persis dengan seluruh ID terbaru milik pengguna. Item yang ditambahkan/dihapus sejak daftar dimuat menghasilkan `conflict`, bukan penghapusan diam-diam. Ini belum mendeteksi semua perubahan urutan/judul bersamaan tanpa protokol revisi.
120
+ - Semua ID harus menjadi milik pengguna yang terautentikasi. ID yang tidak dapat dimodifikasi harus menyebabkan operasi gagal tanpa perubahan parsial.
121
+ - Backend memvalidasi keseluruhan perubahan sebelum menyimpan. Favorit lain yang tidak dicakup input tidak boleh dihapus diam-diam; ketidaksesuaian daftar perlu menghasilkan kegagalan yang dapat ditangani frontend.
122
+ - Hapus dan pengurutan diterapkan dalam satu transaksi. Jika validasi atau penyimpanan gagal, tidak ada sebagian penghapusan atau urutan baru yang terlanjur diterapkan.
123
+ - `orderedIds: []` diperbolehkan saat seluruh favorit pada daftar diedit dimasukkan ke `deletedIds`; hasilnya daftar kosong.
124
+
125
+ Library mengirim satu operasi Simpan setelah pengguna selesai mengedit. Tombol **Batal** tidak memanggil metode mutasi mana pun. Hasil sukses menjadi state tersimpan terbaru; kegagalan mempertahankan draft.
126
+
127
+ Kontrak tidak memakai protokol revisi atau idempotency key. Perubahan anggota daftar menghasilkan konflik; dua perubahan urutan dengan anggota sama memakai penyimpanan terakhir. Adapter tidak melakukan retry mutasi otomatis. Setelah timeout muat ulang daftar: timeout tidak membuktikan transaksi belum terjadi.
128
+
129
+ ## Autentikasi dan kepemilikan
130
+
131
+ Pada API asli, semua operasi wajib:
132
+
133
+ 1. Memverifikasi sesi yang dibawa aplikasi utama dan menentukan ID pengguna dari identitas tervalidasi.
134
+ 2. Membaca dan memodifikasi hanya favorit pengguna tersebut.
135
+ 3. Memeriksa kepemilikan setiap ID, termasuk seluruh item pada permintaan batch.
136
+ 4. Memvalidasi data dan tujuan URL di server; pengecekan duplikasi/validitas frontend bukan pengganti pemeriksaan server.
137
+
138
+ Pengguna yang mengunduh package publik tidak memperoleh sesi atau favorit RSML. Pengguna yang sudah login pun tidak boleh membaca atau mengubah favorit orang lain dengan mengganti ID dalam request. Akses ke URL aplikasi tujuan tetap mengikuti pemeriksaan hak akses aplikasi tujuan itu sendiri.
139
+
140
+ Identitas key sesi pada [kontrak integrasi](DEVELOPMENT.md#sesi-pengguna) hanya membantu lifecycle frontend. Backend tidak memperlakukannya sebagai token autentikasi. Token tidak dimasukkan ke URL favorit atau dibundel dalam artifact package.
141
+
142
+ ## Adapter mock
143
+
144
+ Ekspor development yang tersedia (nama development lokal, belum terbit di npm):
145
+
146
+ ```ts
147
+ import { createMockUserMenuApi } from "@rsmlamongan/user-menu/mock";
148
+
149
+ const api = createMockUserMenuApi({ getSession });
150
+ ```
151
+
152
+ `getSession` mengikuti `UserMenuOptions.getSession` pada panduan development. Factory mengembalikan `UserMenuApi` dan hanya dipakai secara eksplisit di playground atau pengembangan aplikasi pemakai.
153
+
154
+ Opsi factory:
155
+
156
+ ```ts
157
+ type MockOperation = keyof UserMenuApi;
158
+ interface MockUserMenuApiOptions {
159
+ getSession: UserMenuOptions["getSession"];
160
+ storage?: Pick<Storage, "getItem" | "setItem">;
161
+ storageKey?: string;
162
+ initialFavorites?: Record<string, readonly Favorite[]>;
163
+ latencyMs?: number | (() => number);
164
+ shouldFail?: (operation: MockOperation) => boolean;
165
+ }
166
+ ```
167
+
168
+ `storage` default adalah `localStorage`, dibaca ketika operasi berjalan. `storageKey` adalah prefix default `rsml-user-menu:mock:v1`; key final berbentuk `${storageKey}:${encodeURIComponent(userId)}`. `initialFavorites` dipetakan berdasarkan ID pengguna dan hanya digunakan ketika key belum tersedia; daftar kosong yang sudah tersimpan tidak diisi ulang. Jangan memasukkan data nyata atau token ke seed.
169
+
170
+ `latencyMs` dan `shouldFail` dievaluasi saat pemanggilan operasi. Kegagalan simulasi terjadi setelah delay, sebelum baca/tulis hasil. Playground menghapus pilihan kegagalan setelah satu operasi, sedangkan factory sendiri mengikuti hasil callback pada setiap pemanggilan. Storage dapat diinjeksi untuk tes atau demonstrasi terisolasi.
171
+
172
+ Perilaku implementasi:
173
+
174
+ - Data contoh terpisah berdasarkan ID pengguna dan tetap utuh saat drawer ditutup atau halaman demo di-reload melalui persistensi lokal.
175
+ - Dua aplikasi contoh milik pengguna yang sama memakai daftar bersama jika berjalan pada origin dan penyimpanan mock yang sama; jangan membuat daftar terpisah per `appId`.
176
+ - Setiap operasi menangkap pemilik sesi saat dipanggil. Sesi kosong menyebabkan penolakan; pergantian sesi saat simulasi delay tidak memindahkan operasi ke pengguna baru.
177
+ - Hasil dikembalikan sebagai salinan data, sehingga draft UI tidak mengubah penyimpanan tanpa operasi adapter.
178
+ - Pengambilan, penambahan, dan Simpan menggunakan kontrak hasil/urutan yang sama dengan adapter asli.
179
+ - Simulasi delay serta kegagalan baca/tulis tersedia dari playground. Kegagalan yang disimulasikan sebelum commit tidak mengubah penyimpanan.
180
+ - Simpan memvalidasi perubahan dan menyimpan satu hasil akhir. Kegagalan validasi atau penulisan lokal membuat operasi reject.
181
+
182
+ Penyimpanan mock tidak menyimpan token, identitas produksi, atau favorit pengguna sebenarnya. `localStorage` adalah penyimpanan browser per origin, bukan layanan bersama atau pembatas akses yang aman. Hasil mock tidak membuktikan sinkronisasi lintas origin, otorisasi server, penerimaan token RSML, maupun transaksi database produksi.
183
+
184
+ ## Adapter HTTP
185
+
186
+ ```ts
187
+ import { createHttpUserMenuApi } from "@rsmlamongan/user-menu/http";
188
+
189
+ const api = createHttpUserMenuApi({
190
+ baseUrl: "/api/v1", // Sertakan prefix reverse proxy yang dipakai aplikasi.
191
+ request: async ({ method, url, body }) => {
192
+ // client adalah instance Axios aplikasi yang memasang token dan menangani refresh.
193
+ const result = await client.request({
194
+ method,
195
+ url,
196
+ data: body,
197
+ validateStatus: () => true,
198
+ });
199
+ return { status: result.status, body: result.data };
200
+ },
201
+ });
202
+ ```
203
+
204
+ `client` disediakan aplikasi, bukan dependency package. Callback menerima `UserMenuHttpRequest` (`method: "GET" | "POST" | "PUT"`, `url`, `body?`) dan mengembalikan `Promise<UserMenuHttpResponse>` (`status: number`, `body: unknown`). Resolve juga HTTP error, termasuk 401; reject untuk kegagalan transport. Aplikasi menangani JSON, token terkini, refresh, timeout, dan kebijakan client. `baseUrl` wajib root API v1 tanpa query/hash; trailing slash diabaikan. Adapter tidak menyimpan token atau daftar pengguna.
205
+
206
+ | Operasi | Method/path relatif terhadap baseUrl | Kode logis sukses |
207
+ | ------------------- | ------------------------------------ | ----------------- |
208
+ | listFavorites | GET `/user-menu/favorites` | 200 |
209
+ | createFavorite | POST `/user-menu/favorites` | 201 |
210
+ | saveFavoriteChanges | PUT `/user-menu/favorites` | 200 |
211
+
212
+ Body mengikuti input TypeScript di atas. Respons JSON memakai `{ metadata: { code, message, type, timestamp, errors? }, response? }`. Transport JSON backend adalah HTTP 200; adapter memeriksa `metadata.code`, bukan hanya status HTTP. Payload sukses divalidasi dan disalin. Respons rusak atau payload invalid menghasilkan `unavailable`.
213
+
214
+ HTTP 401 boleh tanpa envelope dan dipetakan menjadi `unauthenticated`. Kode logis/HTTP 400 menjadi `invalid-input`; 409 dengan `duplicate` dalam `metadata.errors` menjadi `duplicate`, selain itu `conflict`; kegagalan jaringan/server menjadi `unavailable`. `UserMenuError` yang dilempar callback diteruskan. Tidak ada retry otomatis.
215
+
216
+ Backend memakai JWT existing dan NID positif dari `IAuthUser.Id`, bukan identitas/key sesi di frontend. Tabel `dbo.UserMenuFavorite` pada database Pegawai harus dipasang melalui skrip backend sebelum deployment. Tidak ada migrasi localStorage. Sesuaikan origin yang diizinkan melalui konfigurasi CORS backend, lalu uji token dan satu pengguna dari dua origin aplikasi terhadap environment tujuan.
217
+
218
+ ## Referensi
219
+
220
+ - [Otorisasi pada setiap request dan pemeriksaan kepemilikan](https://cheatsheetseries.owasp.org/cheatsheets/Authorization_Cheat_Sheet.html)
221
+ - [Batas penyimpanan localStorage](https://developer.mozilla.org/en-US/docs/Web/API/Window/localStorage)