@aiquants/authz-react-router 0.6.0 → 0.7.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/README.md CHANGED
@@ -33,7 +33,7 @@ This walkthrough takes a React Router v7 app from nothing to a permission-guarde
33
33
 
34
34
  ```ts
35
35
  // app/services/authz/config.ts
36
- import { defineAuthzSchema, getEffectivePermissions } from "@aiquants/authz-drizzle"
36
+ import { defineAuthzSchema, getEffectivePermissionsMany, listResourceKeys } from "@aiquants/authz-drizzle"
37
37
  import { createAuthz } from "@aiquants/authz-react-router"
38
38
  import { db } from "~/db.server" // your drizzle handle
39
39
  import { Users } from "~/models/Users" // your user table (has numeric id)
@@ -45,8 +45,10 @@ const authz = createAuthz({
45
45
  appKey: "myapp",
46
46
  // request → your user id (or null → checked against "@anonymous" permissions)
47
47
  resolveUserId: async (request) => (await getSessionUser(request))?.id ?? null,
48
- // load + merge this user's grants for one (app, resource)
49
- getEffectivePermissions: (args) => getEffectivePermissions(db, authzTables, args),
48
+ // load + merge this user's grants for MANY resources in one round-trip
49
+ getEffectivePermissions: (args) => getEffectivePermissionsMany(db, authzTables, args),
50
+ // optional: lets `getMyPermissions(request)` report on every registered resource
51
+ listResourceKeys: () => listResourceKeys(db, authzTables, "myapp"),
50
52
  })
51
53
 
52
54
  export const { requirePermission, getMyPermissions } = authz
@@ -54,25 +56,31 @@ export const { requirePermission, getMyPermissions } = authz
54
56
 
55
57
  > If your DB pool can be cold at the first request (serverless / lazy connect), have `resolveUserId` and `getEffectivePermissions` await a connection-ready guard first — otherwise a cold-start DB error can surface as a misleading 403 instead of a 500.
56
58
 
57
- **Step 2 — guard a loader/action and apply scope.** On allow you get `{ userId, scope }`, so you can both authorize *and* narrow the data.
59
+ **Step 2 — guard a loader/action, apply scope, and render the badge.** On allow you get `{ userId, scope, permission }` from a **single** evaluation, so you can authorize, narrow the data, *and* tell the user what they may do — without asking the database a second time.
58
60
 
59
61
  ```ts
60
62
  // app/routes/report.tsx
61
- import { applyRowScope, maskColumns } from "@aiquants/authz-drizzle"
63
+ import { buildRowScopeWhere, maskColumns } from "@aiquants/authz-drizzle"
62
64
  import { requirePermission } from "~/services/authz/config"
63
65
  import { db } from "~/db.server"
64
66
  import { Report } from "~/models/Report"
65
67
 
66
68
  export async function loader({ request }: { request: Request }) {
67
- const { scope } = await requirePermission(request, { resourceKey: "report", action: "read" })
69
+ const { scope, permission } = await requirePermission(request, { resourceKey: "report", action: "read" })
68
70
  // map the scope's logical field names → real columns (per endpoint; the core layer doesn't know your table)
69
71
  const columnMap = { dept: Report.dept, amount: Report.amount }
70
- const rows = await applyRowScope(db.select().from(Report), scope.rowScope, columnMap)
71
- return { rows: maskColumns(rows, scope.columnScope) } // deny disallowed columns before returning
72
+ const where = buildRowScopeWhere(scope.rowScope, columnMap) // undefined = all rows; 1=0 = no rows
73
+ const rows = await (where ? db.select().from(Report).where(where) : db.select().from(Report))
74
+ return { rows: maskColumns(rows, scope.columnScope), permission } // deny disallowed columns before returning
72
75
  }
73
76
  // denied → AuthzDeniedError (throw a 403 Response in your error boundary, or pass options.failureRedirect)
74
77
  ```
75
78
 
79
+ > **Guarding a sub-resource endpoint? Use `requirePermission`, not a hand-rolled check.** Reading a boolean off a
80
+ > permission map and throwing your own 403 duplicates the decision — and a typo in the field name reads as `undefined`,
81
+ > i.e. a silent denial. `requirePermission` is the only thing that decides access; `getMyPermissions` exists to *display*
82
+ > permissions, not to enforce them.
83
+
76
84
  **Step 3 — register the resource + grant.** Insert a `TMResource('myapp','report')` and a `TDRolePermission` for the role(s) that should see it. Either run SQL, or build a tiny admin screen with the CRUD helpers (see the host app's admin guide). Example grant: "role `viewer` may `read` report, rows where dept ∈ {A}, hiding the amount column":
77
85
 
78
86
  ```sql
@@ -85,20 +93,35 @@ FROM dbo_authz.TMRole r JOIN dbo_authz.TMResource res ON res.app_key='myapp' AND
85
93
  WHERE r.role_key='viewer';
86
94
  ```
87
95
 
88
- **Step 4 — show the user their permission (optional).** Feed `getMyPermissions` into a loader and render the indicator.
96
+ **Step 4 — show the user their permissions.** For the page's own resource, the badge data already came back from
97
+ `requirePermission` (Step 2). `getMyPermissions` is for the *other* resources a screen wants to describe:
98
+
99
+ ```ts
100
+ // several named resources — one round-trip, and the returned keys are typed from the argument,
101
+ // so `myPermissions.reprot` is a compile error rather than an `undefined` at runtime
102
+ const myPermissions = await getMyPermissions(request, { resourceKeys: ["report", "report_comment"] })
103
+
104
+ // from a defineResources registry, keeping the keys literal-typed
105
+ const fromRegistry = await getMyPermissions(request, { resourceKeys: resourceKeysOf(REPORT_RESOURCES) })
106
+
107
+ // or every resource registered for the app (needs the `listResourceKeys` port)
108
+ const all = await getMyPermissions(request)
109
+ ```
89
110
 
90
111
  ```tsx
91
- // loader: const myPermissions = await getMyPermissions(request, { resourceKeys: ["report"] })
92
112
  import { MyPermissionIndicator, MyPermissionStatus } from "@aiquants/authz-react-router"
93
113
 
94
114
  // 1. Single indicator badge group
95
115
  <MyPermissionIndicator permission={myPermissions.report} resourceName="Monthly Report" />
96
116
 
97
- // 2. Full "Your Permissions" status bar component for any page header/toolbar
117
+ // 2. Full "your permissions" status bar component for any page header/toolbar
98
118
  <MyPermissionStatus permission={myPermissions.report} resourceName="Monthly Report" />
99
- // → "Your Permissions: [Read ○][Write △][Delete ×]" with tooltip "Target Resource: Monthly Report (report)"
119
+ // → "あなたの権限: [閲覧 ○][編集 △][削除 ×]" with the tooltip "対象リソース: Monthly Report (report)"
100
120
  ```
101
121
 
122
+ > ⚠️ Unlike the admin shell's `labels`, these two components ship **Japanese** defaults (`"あなたの権限"`, `"閲覧"` /
123
+ > `"編集"` / `"削除"`, `"部分制限"`, and the `対象リソース: …` tooltip). Pass `prefixLabel` and `labels` to localize them.
124
+
102
125
  ## Scope cheat-sheet (§3.2)
103
126
 
104
127
  The JSON you put in `row_scope` / `column_scope` (full contract in `@aiquants/authz-core`):
@@ -114,19 +137,60 @@ Multiple roles **union** (wider wins): rows OR'd (any `NULL` ⇒ all rows); colu
114
137
 
115
138
  ## API
116
139
 
117
- - `createAuthz({ appKey, resolveUserId, resolveGroupIds?, getEffectivePermissions, onDeny? })` → `{ requirePermission, getMyPermissions, requireAndGetPermission }`.
140
+ - `createAuthz({ appKey, resolveUserId, resolveGroupIds?, getEffectivePermissions, listResourceKeys?, onDeny? })` → `{ requirePermission, getMyPermissions }`.
141
+ - `getEffectivePermissions({ userId, groupIds?, appKey, resourceKeys })` → `Record<resourceKey, EffectivePermission | null>`. **One call, many resources** — pair it with `getEffectivePermissionsMany` from `@aiquants/authz-drizzle` and N resources cost one round-trip. Keys the port omits are treated as "no permission" (fail-close).
142
+ - `listResourceKeys(request)` → every resource key registered for the app. Optional; configuring it is what makes the no-argument `getMyPermissions(request)` legal. Without it that call throws `AuthzUsageError` naming both remedies instead of guessing.
118
143
  - `resolveGroupIds(request)` → the acting user's group ids. Forwarded automatically to `getEffectivePermissions` by both `requirePermission` and `getMyPermissions`, so group-granted roles count toward effective permissions. Returning `null`/`undefined` (or omitting the port) sends no `groupIds` at all.
119
144
  ⚠️ Without this port, roles assigned to groups in the admin UI have **no runtime effect** — the store writes succeed and the UI looks correct, so the failure is silent. Cover the wiring with a test.
120
- - `requirePermission(request, {resourceKey, action}, options?)` → `{ userId, scope }` on allow (so loaders apply row WHERE / column mask not just 403). Denies throw `AuthzDeniedError` (403), or `throw redirect(options.failureRedirect)` when set.
121
- - `requireAndGetPermission(request, {resourceKey, action}, options?)` → `{ userId, scope, permission }`. Atomically performs guard and fetches matching UI `PermissionView` to prevent page-resource mismatches.
122
- - `defineResources({ ... })` Type-safe centralized resource registry helper for resource keys and display names.
123
- - `getMyPermissions(request, {resourceKeys})` `Record<resourceKey, PermissionView>` for the UI.
145
+ - `requirePermission(request, {resourceKey, action}, options?)` → `{ userId, scope, permission }` on allow: the scope for row WHERE / column mask, **and** the `PermissionView` derived from the very same evaluation (no second lookup, so the guard and the badge can never disagree). Denies throw `AuthzDeniedError` (403), or `throw redirect(options.failureRedirect)` when set. A malformed query (blank `resourceKey`, unknown `action`, or the derived `"write"`) throws `AuthzUsageError` before anything is loaded — a mistake surfaces as a 500 you can read, never as a mysterious 403.
146
+ - `getMyPermissions(request, {resourceKeys})` → `Record<K, PermissionView>` where `K` is the literal union of the keys you passed, so an unrequested key is a **compile error** rather than a runtime `undefined`. `getMyPermissions(request)` reports on every registered resource (requires `listResourceKeys`). Duplicate keys collapse; an empty list asks the database nothing.
147
+ ⚠️ The no-argument form's result **keys are your app's resource inventory**. Every value is still evaluated fail-closed (an unauthenticated caller gets all-false views), but do not hand the whole map to an unauthenticated client if the resource names themselves are sensitive — name the resources you actually render instead.
148
+ - `AuthzUsageError` — thrown for API misuse (missing/ill-typed arguments, missing port). Deliberately distinct from `AuthzDeniedError`: misuse must not masquerade as a denial.
149
+ - `defineResources({ ... })` → Type-safe centralized resource registry helper for resource keys and display names. Keys stay **literal-typed**.
150
+ - `resourceKeysOf(registry)` → the registry's resource keys as a literal union array. Use it instead of `Object.keys(registry)`, which both widens to `string[]` (losing the key check on the permission map) and returns *property names* — wrong for entries that set an explicit `key`.
124
151
  - `toPermissionView(resourceKey, perm)` → serializable view (loader → client).
125
- - `usePermission(view)` → ○△× display states (`readState`/`writeState`/`deleteState`); pure, safe in render.
152
+ - `usePermission(view)` → the same `can*` booleans plus ○△× display states (`readState`/`writeState`/`deleteState`); pure, safe in render.
126
153
  - `<MyPermissionIndicator permission={view} resourceName="Documents" expectedResourceKey="documents" />` → read / write(create∪update) / delete as ○△×. `read` is △ when row/column-restricted; `write` is ○ only when both create & update are held, △ when one. Hover shows resource name & key tooltip. Automatically flags mismatches if `permission.resourceKey !== expectedResourceKey`.
127
154
  - `<MyPermissionStatus permission={view} resourceName="Documents" expectedResourceKey="documents" prefixLabel="Your Permissions" />` → Reusable status component combining prefix label and permission indicator badges for any application page.
128
155
  - `<MyPermissionGroupStatus items={[{ resourceName: "Primary Resource", permission: primaryView }, { resourceName: "Sub Resource", permission: subView, level: 1 }]} variant="pills" | "popover" size="xs" labels={{ fullWrite: "Writable", readOnly: "Read-only" }} />` → Multi-resource permission widget for pages with multiple distinct resource scopes. Supports inline status tags (`variant="pills"`), single-button popover cards (`variant="popover"`), hierarchical tree indentation via `level` / `children`, and fully customizable status label mappings (`labels`).
129
156
 
157
+ ## One vocabulary, one decision
158
+
159
+ Two rules make the whole surface predictable, and both exist because their absence produced real production bugs:
160
+
161
+ 1. **Every permission boolean is `can*`, everywhere.** `PermissionView` and `usePermission()` expose the *same*
162
+ `canRead` / `canCreate` / `canUpdate` / `canDelete` / `canWrite` names. Previously the view said `read` while the hook
163
+ said `canRead`, so reading `view.canRead` yielded `undefined` — falsy, therefore a **silent denial** that looked like
164
+ an authorization bug. A verb-shaped field is also undestructurable (`const { delete } = view` is a syntax error).
165
+ 2. **The guard decides once.** `requirePermission` returns the UI view built from the permission it just evaluated, so
166
+ there is no second query that could return a different answer. (The old `requireAndGetPermission` claimed to be
167
+ atomic but issued two independent lookups; it is gone — `requirePermission` always returns `permission`.)
168
+
169
+ ### Migrating from 0.6.x
170
+
171
+ | Before | Now |
172
+ | --- | --- |
173
+ | `permissionView.read` / `.create` / `.update` / `.delete` / `.write` | `.canRead` / `.canCreate` / `.canUpdate` / `.canDelete` / `.canWrite` |
174
+ | `requireAndGetPermission(request, query)` | `requirePermission(request, query)` — it already returns `permission` |
175
+ | `getEffectivePermissions: (args) => …(args.resourceKey)` (one resource) | `getEffectivePermissions: (args) => …(args.resourceKeys)` returning a record (many resources) |
176
+ | `createAuthzAdminApp({ …, getMyPermissions })` | port removed — the admin shell reads its badge off the guard |
177
+ | `createAuthzAdminApp({ requirePermission })` resolving to `{ userId }` | must now resolve to `{ userId, permission }` — pass `createAuthz`'s `requirePermission` directly, or add `permission` to a hand-rolled guard |
178
+ | `usePermission(view)` → `{ canRead, canWrite, canDelete, …States }` | adds `canCreate` / `canUpdate`, so it is a superset of the view's booleans |
179
+ | `expectedResourceKey?: string` on the indicator components | now `NoInfer<K>` — bound to the permission's own key, so a mismatch is a compile error (0.6.x code that deliberately passed a different key stops compiling) |
180
+ | `createAuthzAdminServer(...).loadMyPermissions` | removed — `layoutGuard` returns `{ userId, myPermissions }` from the guard it already runs |
181
+ | `getMyPermissions(request, { resourceKeys: someStringArray })` → total map | values are now `PermissionView \| undefined` whenever the keys are not literals (a runtime-built list cannot promise which keys exist). Pass literals or `resourceKeysOf(registry)` to keep the total map, or add the presence check |
182
+ | `getMyPermissions(request)` → `TypeError` | configure `listResourceKeys`, or pass `{ resourceKeys }` — otherwise a named `AuthzUsageError` |
183
+
184
+ **Requirements.** TypeScript ≥ 5.4 to consume the emitted `.d.ts` (the indicator props use the built-in `NoInfer`), and
185
+ an ES2022 runtime for `Object.hasOwn` (Node ≥ 16.9 — the package already declares `engines.node >= 18` — and any
186
+ browser from 2022 on).
187
+
188
+ > `AuthzUsageError` is a **programmer** error, so it travels like any other thrown error: React Router hands the
189
+ > boundary a sanitized error outside development, and the actionable message lands in your **server log**. Read it
190
+ > there — the browser will only show the generic 500 body.
191
+
192
+ `readPartial` and `actions` keep their names (they are not booleans about an action).
193
+
130
194
  ## Admin UI tabs
131
195
 
132
196
  The splat-mounted admin app (`createAuthzAdminApp`) serves `roles`, `resources`, `role_permissions`, `user_roles`, plus:
package/dist/index.d.mts CHANGED
@@ -2,10 +2,36 @@ import * as react_router from 'react-router';
2
2
  import * as react from 'react';
3
3
  import { ReactNode, CSSProperties } from 'react';
4
4
  import * as _aiquants_authz_core from '@aiquants/authz-core';
5
- import { EffectivePermission, DenyInfo, AuthzAction, GuardResult, AuthzGrant, AuthzUser, AuthzRole, AuthzResource, AuthzAssignment, AuthzGroupRoleAssignment, AuthzGroupSummary, AuthzAdminStore } from '@aiquants/authz-core';
5
+ import { EffectivePermission, DenyInfo, AuthzAction, MergedScope, AuthzGrant, AuthzUser, AuthzRole, AuthzResource, AuthzAssignment, AuthzGroupRoleAssignment, AuthzGroupSummary, AuthzAdminStore } from '@aiquants/authz-core';
6
+
7
+ /**
8
+ * React Router (v7) adapter: binds `appKey` + the DI ports and exposes the loader/action authorization API.
9
+ * React Router (v7) 向けアダプタ。`appKey` と DI ポートを束ね、loader/action 用の認可 API を公開するモジュール。
10
+ *
11
+ * 設計上の中心的な不変条件は **判定は 1 回だけ** ということ。ガード (`requirePermission`) と
12
+ * UI 表示用の権限ビューは同一の実効権限から導出されるため、「画面は開けるのに一覧では false」
13
+ * という乖離が構造的に起こり得ない。
14
+ */
6
15
 
7
16
  type Awaitable<T> = T | Promise<T>;
8
17
  type AuthzUserId = string | number;
18
+ /**
19
+ * Thrown when the API itself is called incorrectly (bad arguments, missing port) — a programmer error,
20
+ * never an authorization decision. It is deliberately NOT an `AuthzDeniedError`: a misuse must surface as a
21
+ * loud 500, not as a silent 403 that looks like a permission problem.
22
+ * API の使い方が誤っている場合に送出される例外。認可拒否 (403) とは明確に区別されるプログラマ向けエラー。
23
+ */
24
+ declare class AuthzUsageError extends Error {
25
+ constructor(message: string);
26
+ }
27
+ /** Arguments handed to the {@link AuthzConfig.getEffectivePermissions} port. 実効権限ポートへ渡す引数。 */
28
+ type EffectivePermissionsQuery = {
29
+ userId: AuthzUserId | null;
30
+ groupIds?: (string | number)[];
31
+ appKey: string;
32
+ /** Always a de-duplicated, non-empty list — the port may load them all in one round-trip. Read-only: the adapter reports on exactly these keys. */
33
+ resourceKeys: readonly string[];
34
+ };
9
35
  type AuthzConfig = {
10
36
  /** Stable app identifier (e.g. "quants"). Bound into every permission query. */
11
37
  appKey: string;
@@ -13,13 +39,18 @@ type AuthzConfig = {
13
39
  resolveUserId: (request: Request) => Awaitable<AuthzUserId | null | undefined>;
14
40
  /** Optionally resolve acting user's group IDs from the request or session. */
15
41
  resolveGroupIds?: (request: Request) => Awaitable<(string | number)[] | null | undefined>;
16
- /** Load effective permissions for (user, app, resource) — typically via @aiquants/authz-drizzle. */
17
- getEffectivePermissions: (args: {
18
- userId: AuthzUserId | null;
19
- groupIds?: (string | number)[];
20
- appKey: string;
21
- resourceKey: string;
22
- }) => Awaitable<EffectivePermission | null | undefined>;
42
+ /**
43
+ * Load effective permissions for (user, app, resources) — **one call, many resources** (typically
44
+ * `getEffectivePermissionsMany` from `@aiquants/authz-drizzle`). A key with no grants may be omitted or
45
+ * mapped to `null`; both mean "no permission" (fail-close).
46
+ */
47
+ getEffectivePermissions: (args: EffectivePermissionsQuery) => Awaitable<Record<string, EffectivePermission | null | undefined> | null | undefined>;
48
+ /**
49
+ * Optional: every resource key registered for this app (typically `listResourceKeys` from
50
+ * `@aiquants/authz-drizzle`). Configuring it is what makes `getMyPermissions(request)` — with no resource
51
+ * list — legal; without it that call throws {@link AuthzUsageError} instead of guessing.
52
+ */
53
+ listResourceKeys?: (request: Request) => Awaitable<readonly string[] | null | undefined>;
23
54
  /** Default deny handler. If omitted, denies throw AuthzDeniedError (403). */
24
55
  onDeny?: (info: DenyInfo) => void;
25
56
  };
@@ -27,41 +58,82 @@ type RequirePermissionOptions = {
27
58
  /** When set, denials `throw redirect(failureRedirect)` instead of throwing 403. */
28
59
  failureRedirect?: string;
29
60
  };
30
- /** Serializable per-resource permission summary for UI (loader → component). */
31
- type PermissionView = {
32
- resourceKey: string;
33
- read: boolean;
34
- create: boolean;
35
- update: boolean;
36
- delete: boolean;
61
+ /**
62
+ * Serializable per-resource permission summary for UI (loader → component).
63
+ * UI 向けの直列化可能なリソース単位権限サマリ (loader からコンポーネントへ受け渡す形)。
64
+ *
65
+ * 真偽値は `can` 接頭辞で統一されている。`delete` のような素の動詞名は分割代入できず
66
+ * (`const { delete } = view` は構文エラー)、フックの戻り値との二重語彙も生むため採用しない。
67
+ */
68
+ type PermissionView<K extends string = string> = {
69
+ resourceKey: K;
70
+ canRead: boolean;
71
+ canCreate: boolean;
72
+ canUpdate: boolean;
73
+ canDelete: boolean;
37
74
  /** create ∪ update */
38
- write: boolean;
75
+ canWrite: boolean;
39
76
  /** read granted but row and/or column restricted (→ △ partial). */
40
77
  readPartial: boolean;
41
78
  actions: AuthzAction[];
42
79
  };
43
- /** Map an EffectivePermission to a serializable {@link PermissionView}. */
44
- declare function toPermissionView(resourceKey: string, perm: EffectivePermission | null | undefined): PermissionView;
80
+ /** Result of a successful {@link createAuthz} guard. ガード成功時の戻り値。 */
81
+ type RequirePermissionResult<K extends string = string> = {
82
+ userId: AuthzUserId | null;
83
+ /** Effective row/column scope for the guarded action — apply as WHERE / column mask. */
84
+ scope: MergedScope;
85
+ /** UI view derived from the **same** evaluation as the guard decision (no second lookup). */
86
+ permission: PermissionView<K>;
87
+ };
88
+ /**
89
+ * Permission views keyed by the exact resource keys that were requested.
90
+ * 要求したキーで型付けした権限ビュー群。
91
+ *
92
+ * リテラルのキーを渡した場合だけ「そのキーは必ず存在する」形になる。`string[]` のようにキーが実行時に
93
+ * しか分からない場合は `| undefined` を含む索引型へ落ち、存在チェックを強制する (総和型の索引を
94
+ * 名乗ると、要求していないキーが型の上では必ず存在することになり、元の不具合と同じ罠に戻る)。
95
+ *
96
+ * ⚠️ キーは配列の **要素型** から導かれる。`flag ? ["a"] : ["b"]` のように要素型が合併になる式では、
97
+ * 実行時に片方しか無くても型の上では両方が存在する扱いになる。リテラル配列を直接渡すか
98
+ * `resourceKeysOf(registry)` を使うこと。
99
+ * ⚠️ 戻り値を `Record<string, …>` 型の変数へ代入すると、その文脈から `K` が `string` へ広がり
100
+ * 索引型 (値は `| undefined`) に落ちる。リテラルキーの保証が要る箇所では代入先を広げないこと。
101
+ * ⚠️ 戻り値は素のオブジェクト。要求していない `toString` などのキーを読むと `Object.prototype` の
102
+ * メンバが返る (索引型の `| undefined` とは一致しない)。要求したキーだけを読むこと。
103
+ */
104
+ type PermissionViewMap<K extends string> = string extends K ? Record<string, PermissionView | undefined> : {
105
+ [P in K]: PermissionView<P>;
106
+ };
107
+ /**
108
+ * Map an EffectivePermission to a serializable {@link PermissionView}.
109
+ * 実効権限を直列化可能な {@link PermissionView} へ変換する処理。
110
+ *
111
+ * @returns The UI-facing permission summary (all-false when `perm` is absent). UI 表示用の権限サマリ。
112
+ */
113
+ declare function toPermissionView<K extends string>(resourceKey: K, perm: EffectivePermission | null | undefined): PermissionView<K>;
45
114
  /**
46
115
  * React Router adapter. Binds `appKey` and the DI deps, exposing:
47
- * - `requirePermission(request, {resourceKey, action}, options?)` → `{ userId, scope }` on allow
48
- * (apply row WHERE / column mask with the returned scope); denies throw 403 or redirect.
49
- * - `getMyPermissions(request, {resourceKeys})` → `Record<resourceKey, PermissionView>` for the UI.
116
+ * - `requirePermission(request, {resourceKey, action}, options?)` → `{ userId, scope, permission }` on allow
117
+ * (apply row WHERE / column mask with `scope`; render `permission` in the UI); denies throw 403 or redirect.
118
+ * - `getMyPermissions(request, {resourceKeys})` → `Record<resourceKey, PermissionView>` for the UI,
119
+ * or `getMyPermissions(request)` for every registered resource when `listResourceKeys` is configured.
120
+ * `appKey` と DI ポートを束ね、ガードと自己権限取得の 2 面だけを公開するファクトリ。
121
+ *
122
+ * @returns The bound `requirePermission` / `getMyPermissions` pair. 結線済みの認可 API 一式。
50
123
  */
51
124
  declare function createAuthz(config: AuthzConfig): {
52
- requirePermission: (request: Request, query: {
53
- resourceKey: string;
125
+ requirePermission: <K extends string>(request: Request, query: {
126
+ resourceKey: K;
54
127
  action: AuthzAction;
55
- }, options?: RequirePermissionOptions) => Promise<GuardResult<AuthzUserId>>;
56
- getMyPermissions: (request: Request, query: {
57
- resourceKeys: string[];
58
- }) => Promise<Record<string, PermissionView>>;
59
- requireAndGetPermission: (request: Request, query: {
60
- resourceKey: string;
61
- action: AuthzAction;
62
- }, options?: RequirePermissionOptions) => Promise<GuardResult<AuthzUserId> & {
63
- permission: PermissionView;
64
- }>;
128
+ }, options?: RequirePermissionOptions) => Promise<RequirePermissionResult<K>>;
129
+ getMyPermissions: {
130
+ <K extends string>(request: Request, options: {
131
+ resourceKeys: readonly K[];
132
+ }): Promise<PermissionViewMap<K>>;
133
+ (request: Request, options?: {
134
+ resourceKeys?: readonly string[];
135
+ }): Promise<Record<string, PermissionView | undefined>>;
136
+ };
65
137
  };
66
138
 
67
139
  /** Authz admin UI strings (i18n). Defaults are English; override per-section via `createAuthzAdminRoutes(..., { labels })`. */
@@ -182,7 +254,7 @@ type WithLabels = {
182
254
  labels: AuthzAdminLabels;
183
255
  };
184
256
  type AuthzLayoutData = WithLabels & {
185
- myPermissions: Record<string, PermissionView>;
257
+ myPermissions: Record<string, PermissionView | undefined>;
186
258
  adminResourceKey: string;
187
259
  basePath?: string;
188
260
  };
@@ -286,18 +358,17 @@ type CreateAuthzAdminServerOptions = {
286
358
  /** 未ログインをログインへ誘導する(例: throw redirect(...))。 */
287
359
  requireUser: (request: Request) => Promise<unknown>;
288
360
  /**
289
- * 認可ガード(アプリの createAuthz 由来)。allow { userId } を返す。
290
- * 0.2.0GuardResult.userId nullable 化したため、戻り型も null を受ける。
291
- * userId==null(未解決/万一の public 付与)createAuthzAdminServer 内で deny する
361
+ * Authorization guard supplied by the app (from `createAuthz`); allow `{ userId, permission }`.
362
+ * 認可ガード (アプリの createAuthz 由来)。allow { userId, permission } を返すポート。
363
+ * `userId` は nullable(未解決/万一の public 付与)であり、createAuthzAdminServer 内で deny する
292
364
  * (管理経路は監査・自己権限計算に実 id が必須のため非 null を保証)。
365
+ * `permission` は **同一判定から導かれた** 自己権限ビューであり、表示用に再取得しない
366
+ * (再取得は「ガードは通るのにバッジは権限なし」という乖離の温床)。
293
367
  */
294
368
  requirePermission: (request: Request, query: PermissionQuery) => Promise<{
295
369
  userId: string | number | null;
370
+ permission: PermissionView;
296
371
  }>;
297
- /** 自己権限表示用(アプリの createAuthz 由来)。 */
298
- getMyPermissions: (request: Request, args: {
299
- resourceKeys: string[];
300
- }) => Promise<Record<string, PermissionView>>;
301
372
  /** 文言の部分上書き(既定は日本語)。 */
302
373
  labels?: PartialAuthzAdminLabels;
303
374
  };
@@ -307,9 +378,9 @@ declare function createAuthzAdminServer(opts: CreateAuthzAdminServerOptions): {
307
378
  resourceKey: string;
308
379
  layoutGuard: (request: Request) => Promise<{
309
380
  userId: string | number;
381
+ myPermissions: Record<string, PermissionView | undefined>;
310
382
  }>;
311
- loadMyPermissions: (request: Request) => Promise<Record<string, PermissionView>>;
312
- /** /authz レイアウト: requireUser(302) → requirePermission(read, 403) → 自己権限取得。 */
383
+ /** /authz レイアウト: requireUser(302) requirePermission(read, 403)。自己権限はガードの戻り値から得る。 */
313
384
  layout: {
314
385
  loader({ request }: Req): Promise<AuthzLayoutData>;
315
386
  };
@@ -376,6 +447,12 @@ type CreateAuthzAdminAppOptions = CreateAuthzAdminServerOptions & {
376
447
  /** マウントのベースパス(タブリンク/未指定セグメントのリダイレクト先)。既定 `/authz`。 */
377
448
  basePath?: string;
378
449
  };
450
+ /**
451
+ * Build the loader/action pair that serves the whole authorization admin UI from one splat route.
452
+ * 認可管理 UI 全体を 1 本のスプラットルートで提供する loader/action 対を生成するファクトリ。
453
+ *
454
+ * @returns The route `loader` / `action` plus the underlying server for direct use. ルートの loader/action と内部サーバ。
455
+ */
379
456
  declare function createAuthzAdminApp(opts: CreateAuthzAdminAppOptions): {
380
457
  loader: (args: RouteArgs) => Promise<{
381
458
  labels: AuthzAdminLabels;
@@ -383,14 +460,14 @@ declare function createAuthzAdminApp(opts: CreateAuthzAdminAppOptions): {
383
460
  selfAdminRoleIds?: number[];
384
461
  segment: string;
385
462
  basePath: string;
386
- myPermissions: Record<string, PermissionView>;
463
+ myPermissions: Record<string, PermissionView | undefined>;
387
464
  adminResourceKey: string;
388
465
  } | {
389
466
  labels: AuthzAdminLabels;
390
467
  resources: _aiquants_authz_core.AuthzResource[];
391
468
  segment: string;
392
469
  basePath: string;
393
- myPermissions: Record<string, PermissionView>;
470
+ myPermissions: Record<string, PermissionView | undefined>;
394
471
  adminResourceKey: string;
395
472
  } | {
396
473
  labels: AuthzAdminLabels;
@@ -406,7 +483,7 @@ declare function createAuthzAdminApp(opts: CreateAuthzAdminAppOptions): {
406
483
  }>;
407
484
  segment: string;
408
485
  basePath: string;
409
- myPermissions: Record<string, PermissionView>;
486
+ myPermissions: Record<string, PermissionView | undefined>;
410
487
  adminResourceKey: string;
411
488
  } | {
412
489
  labels: AuthzAdminLabels;
@@ -420,7 +497,7 @@ declare function createAuthzAdminApp(opts: CreateAuthzAdminAppOptions): {
420
497
  selfAdminRoleIds?: number[];
421
498
  segment: string;
422
499
  basePath: string;
423
- myPermissions: Record<string, PermissionView>;
500
+ myPermissions: Record<string, PermissionView | undefined>;
424
501
  adminResourceKey: string;
425
502
  } | {
426
503
  labels: AuthzAdminLabels;
@@ -429,7 +506,7 @@ declare function createAuthzAdminApp(opts: CreateAuthzAdminAppOptions): {
429
506
  groups: _aiquants_authz_core.AuthzGroupSummary[];
430
507
  segment: string;
431
508
  basePath: string;
432
- myPermissions: Record<string, PermissionView>;
509
+ myPermissions: Record<string, PermissionView | undefined>;
433
510
  adminResourceKey: string;
434
511
  }>;
435
512
  action: (args: RouteArgs) => Promise<unknown>;
@@ -438,8 +515,8 @@ declare function createAuthzAdminApp(opts: CreateAuthzAdminAppOptions): {
438
515
  resourceKey: string;
439
516
  layoutGuard: (request: Request) => Promise<{
440
517
  userId: string | number;
518
+ myPermissions: Record<string, PermissionView | undefined>;
441
519
  }>;
442
- loadMyPermissions: (request: Request) => Promise<Record<string, PermissionView>>;
443
520
  layout: {
444
521
  loader({ request }: {
445
522
  request: Request;
@@ -608,12 +685,16 @@ declare function getPermissionSummary(permission: PermissionView | null | undefi
608
685
  */
609
686
  declare function MyPermissionGroupStatus({ items, prefixLabel, variant, size, labels, className, style }: MyPermissionGroupStatusProps): react.JSX.Element;
610
687
 
611
- type MyPermissionIndicatorProps = {
612
- permission: PermissionView | null | undefined;
688
+ type MyPermissionIndicatorProps<K extends string = string> = {
689
+ permission: PermissionView<K> | null | undefined;
613
690
  /** Human-readable resource display name (e.g. "文書データ"). Falls back to permission.resourceKey. */
614
691
  resourceName?: string;
615
- /** Expected resource key to detect mismatches with permission.resourceKey during development. */
616
- expectedResourceKey?: string;
692
+ /**
693
+ * Expected resource key. Typed as `NoInfer<K>`, so when the permission carries a literal key
694
+ * (`getMyPermissions` / `requirePermission` both do) a mismatch is a **compile error**; the runtime
695
+ * detector below stays for dynamically-keyed and JavaScript callers.
696
+ */
697
+ expectedResourceKey?: NoInfer<K>;
617
698
  /** Whether to include resource name/key tooltip. Default true. */
618
699
  showResourceTooltip?: boolean;
619
700
  labels?: {
@@ -625,7 +706,7 @@ type MyPermissionIndicatorProps = {
625
706
  showScopeSummary?: boolean;
626
707
  className?: string;
627
708
  };
628
- type MyPermissionStatusProps = MyPermissionIndicatorProps & {
709
+ type MyPermissionStatusProps<K extends string = string> = MyPermissionIndicatorProps<K> & {
629
710
  /** Label prefix shown before the indicator badges. Default: "あなたの権限" */
630
711
  prefixLabel?: string;
631
712
  /** Whether to display the prefix label. Default: true */
@@ -642,12 +723,12 @@ declare function formatResourceTooltip(resourceKey?: string, resourceName?: stri
642
723
  * Display permission indicator badges for read, write, and delete capabilities.
643
724
  * 閲覧・編集・削除の権限状態バッジ群を描画。
644
725
  */
645
- declare function MyPermissionIndicator({ permission, resourceName, expectedResourceKey, showResourceTooltip, labels, showScopeSummary, className }: MyPermissionIndicatorProps): react.JSX.Element;
726
+ declare function MyPermissionIndicator<K extends string = string>({ permission, resourceName, expectedResourceKey, showResourceTooltip, labels, showScopeSummary, className }: MyPermissionIndicatorProps<K>): react.JSX.Element;
646
727
  /**
647
728
  * Reusable "Your Permissions" status bar component suitable for any application page.
648
729
  * 「あなたの権限」ラベルと権限状態インジケータを束ねた再利用可能なコンポーネント。
649
730
  */
650
- declare function MyPermissionStatus({ permission, resourceName, expectedResourceKey, prefixLabel, showPrefixLabel, showResourceTooltip, labels, showScopeSummary, className, style }: MyPermissionStatusProps): react.JSX.Element;
731
+ declare function MyPermissionStatus<K extends string = string>({ permission, resourceName, expectedResourceKey, prefixLabel, showPrefixLabel, showResourceTooltip, labels, showScopeSummary, className, style, }: MyPermissionStatusProps<K>): react.JSX.Element;
651
732
 
652
733
  /**
653
734
  * Resource definition and registry utilities for preventing page-resource mismatches.
@@ -670,19 +751,66 @@ type ResourceDefinitionInput = {
670
751
  description?: string;
671
752
  };
672
753
  type ResourceRegistry<T extends Record<string, ResourceDefinition>> = T;
754
+ /**
755
+ * The resource key an entry resolves to: its explicit `key`, otherwise the property name.
756
+ * `key` を明示していればそれ、無ければプロパティ名。
757
+ *
758
+ * `Extract<…, string>` を挟むのは `key?: string` (省略可) が `string | undefined` になり、素の
759
+ * `extends string` では偽になってプロパティ名を名乗ってしまうため (実行時は `key` を使うので型が嘘になる)。
760
+ * `key` を書いていないエントリでは `Extract` が `never` になり、プロパティ名へ落ちる。
761
+ */
762
+ type ResolvedResourceKey<PropertyName extends string, Input extends ResourceDefinitionInput> = [Extract<Input["key"], string>] extends [never] ? PropertyName : Extract<Input["key"], string>;
673
763
  /**
674
764
  * Define type-safe resource registry for an application.
675
765
  * アプリ全体のリソース定義(キー・表示名・説明)を一元管理するヘルパー。
766
+ *
767
+ * 各エントリの `key` はリテラル型のまま保持する。`Record<string, …>` へ広げてしまうと
768
+ * {@link resourceKeysOf} が `string[]` に落ち、権限マップのキー型検査が効かなくなるため。
769
+ *
770
+ * @returns The registry with literal-typed resource keys. リソースキーをリテラル型で保持したレジストリ。
676
771
  */
677
- declare function defineResources<T extends Record<string, ResourceDefinitionInput>>(resources: T): {
678
- [K in keyof T & string]: ResourceDefinition;
772
+ declare function defineResources<const T extends Record<string, ResourceDefinitionInput>>(resources: T): {
773
+ [K in keyof T & string]: {
774
+ key: ResolvedResourceKey<K, T[K]>;
775
+ name: string;
776
+ description?: string;
777
+ };
679
778
  };
779
+ /**
780
+ * Extract a registry's resource keys with their literal types preserved.
781
+ * レジストリのリソースキーをリテラル型を保ったまま取り出す処理。
782
+ *
783
+ * `Object.keys(registry)` は `string[]` になり、`getMyPermissions` の戻り値も索引型へ落ちてキーの誤りを
784
+ * 検出できなくなる。加えて `Object.keys` は **プロパティ名** を返すため、`key` を明示したエントリでは
785
+ * 実際のリソースキーと食い違う。
786
+ *
787
+ * ⚠️ 1 エントリでもリテラルでない `key` (例: 変数由来の `string`) を持つと、合併の性質上レジストリ全体の
788
+ * キー型が `string` へ広がり、他のエントリ分のコンパイル時検査も無効になる。`key` は必ずリテラルで書くこと。
789
+ *
790
+ * @returns The registry's resource keys as a literal union array. リテラル合併型のリソースキー配列。
791
+ */
792
+ declare function resourceKeysOf<T extends Record<string, {
793
+ key: string;
794
+ }>>(registry: T): Array<T[keyof T & string]["key"]>;
795
+
796
+ /**
797
+ * Display-state derivation for a {@link PermissionView} (pure, no React state involved).
798
+ * {@link PermissionView} から表示状態を導出するモジュール (React の状態は一切持たない純粋関数)。
799
+ */
680
800
 
681
801
  type PermState = "full" | "partial" | "none";
802
+ /**
803
+ * Display-oriented reading of a {@link PermissionView}: the same `can*` booleans (safe defaults when the
804
+ * view is absent) plus the ○△× tri-state used by the indicator components.
805
+ * {@link PermissionView} の表示向け読み取り結果。同名の `can*` 真偽値と ○△× の三値状態を併せ持つ形。
806
+ */
682
807
  type UsePermission = {
683
808
  canRead: boolean;
684
- canWrite: boolean;
809
+ canCreate: boolean;
810
+ canUpdate: boolean;
685
811
  canDelete: boolean;
812
+ /** create ∪ update */
813
+ canWrite: boolean;
686
814
  /** read: none(×) / partial(△, row|col restricted) / full(○). */
687
815
  readState: PermState;
688
816
  /** write = create ∪ update: full(○ both) / partial(△ one) / none(×). */
@@ -692,7 +820,10 @@ type UsePermission = {
692
820
  /**
693
821
  * Derive ○△× display states from a {@link PermissionView}. Pure (safe to call in render);
694
822
  * named `usePermission` for ergonomic call-sites.
823
+ * {@link PermissionView} から ○△× の表示状態を導出する純粋関数 (レンダー内から安全に呼べる形)。
824
+ *
825
+ * @returns The permission booleans plus their display states. 権限の真偽値と表示状態の組。
695
826
  */
696
827
  declare function usePermission(view: PermissionView | null | undefined): UsePermission;
697
828
 
698
- export { AuthzAdminAppView, type AuthzAdminAppViewProps, type AuthzAdminLabels, type AuthzConfig, AuthzErrorBoundary, type AuthzGrantsData, AuthzGrantsView, type AuthzGraphData, AuthzGraphView, type AuthzGroupRolesData, AuthzGroupRolesView, AuthzLayout, type AuthzLayoutData, type AuthzLayoutProps, type AuthzLayoutShellProps, type AuthzNodeType, type AuthzResourcesData, AuthzResourcesView, type AuthzRolesData, AuthzRolesView, type AuthzUserId, type AuthzUserRolesData, AuthzUserRolesView, type CreateAuthzAdminAppOptions, type CreateAuthzAdminServerOptions, ErrorAlert, Field, type MyPermissionGroupItem, MyPermissionGroupStatus, type MyPermissionGroupStatusProps, MyPermissionIndicator, type MyPermissionIndicatorProps, MyPermissionStatus, type MyPermissionStatusProps, type PartialAuthzAdminLabels, type PermState, type PermissionView, type RequirePermissionOptions, type ResourceDefinition, type ResourceDefinitionInput, type ResourceRegistry, type UsePermission, createAuthz, createAuthzAdminApp, createAuthzAdminServer, defaultAuthzAdminLabels, defineResources, flattenGroupItems, formatResourceTooltip, getPermissionSummary, makeAuthzErrorBoundary, resolveLabels, toPermissionView, ui, usePermission };
829
+ export { AuthzAdminAppView, type AuthzAdminAppViewProps, type AuthzAdminLabels, type AuthzConfig, AuthzErrorBoundary, type AuthzGrantsData, AuthzGrantsView, type AuthzGraphData, AuthzGraphView, type AuthzGroupRolesData, AuthzGroupRolesView, AuthzLayout, type AuthzLayoutData, type AuthzLayoutProps, type AuthzLayoutShellProps, type AuthzNodeType, type AuthzResourcesData, AuthzResourcesView, type AuthzRolesData, AuthzRolesView, AuthzUsageError, type AuthzUserId, type AuthzUserRolesData, AuthzUserRolesView, type CreateAuthzAdminAppOptions, type CreateAuthzAdminServerOptions, type EffectivePermissionsQuery, ErrorAlert, Field, type MyPermissionGroupItem, MyPermissionGroupStatus, type MyPermissionGroupStatusProps, MyPermissionIndicator, type MyPermissionIndicatorProps, MyPermissionStatus, type MyPermissionStatusProps, type PartialAuthzAdminLabels, type PermState, type PermissionView, type PermissionViewMap, type RequirePermissionOptions, type RequirePermissionResult, type ResourceDefinition, type ResourceDefinitionInput, type ResourceRegistry, type UsePermission, createAuthz, createAuthzAdminApp, createAuthzAdminServer, defaultAuthzAdminLabels, defineResources, flattenGroupItems, formatResourceTooltip, getPermissionSummary, makeAuthzErrorBoundary, resolveLabels, resourceKeysOf, toPermissionView, ui, usePermission };