@meith/authorization 0.16.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/src/fixture.ts ADDED
@@ -0,0 +1,226 @@
1
+ import { emptyPermissionSet, type PermissionSet } from '@meith/core'
2
+
3
+ import { combinePermissionSets } from './combine'
4
+ import type {
5
+ Actor,
6
+ AuthorizationSource,
7
+ ForumOverride,
8
+ GroupDefaults,
9
+ ModeratorAppointment,
10
+ } from './types'
11
+
12
+ export function makePermissionSet(overrides: Partial<PermissionSet>): PermissionSet {
13
+ return { ...emptyPermissionSet(), ...overrides }
14
+ }
15
+
16
+ export const GROUP = {
17
+ guest: 1,
18
+ registered: 2,
19
+ veterans: 3,
20
+ superMod: 5,
21
+ admin: 6,
22
+ } as const
23
+
24
+ const PARTICIPANT_FORUM_GRANTS: Partial<PermissionSet> = {
25
+ canView: true,
26
+ canViewThreads: true,
27
+ canViewOthersThreads: true,
28
+ canSearch: true,
29
+ }
30
+
31
+ const POSTER_FORUM_GRANTS: Partial<PermissionSet> = {
32
+ ...PARTICIPANT_FORUM_GRANTS,
33
+ canSubscribe: true,
34
+ canPostThreads: true,
35
+ canPostReplies: true,
36
+ canEditOwnPosts: true,
37
+ canDeleteOwnPosts: true,
38
+ canUploadAttachments: true,
39
+ canDownloadAttachments: true,
40
+ requiresThreadApproval: false,
41
+ requiresPostApproval: false,
42
+ requiresApprovalOnEdit: false,
43
+ }
44
+
45
+ export const GROUP_DEFAULTS: Record<number, PermissionSet> = {
46
+ [GROUP.guest]: makePermissionSet({
47
+ ...PARTICIPANT_FORUM_GRANTS,
48
+ canViewProfiles: true,
49
+ }),
50
+
51
+ [GROUP.registered]: makePermissionSet({
52
+ ...POSTER_FORUM_GRANTS,
53
+ canViewProfiles: true,
54
+ canViewMemberList: true,
55
+ canUsePrivateMessages: true,
56
+ canReportContent: true,
57
+ editTimeLimitMinutes: 30,
58
+ maxPostsPerDay: 50,
59
+ }),
60
+
61
+ [GROUP.veterans]: makePermissionSet({
62
+ canPostPolls: true,
63
+ canVotePolls: true,
64
+ maxPostsPerDay: 0,
65
+ }),
66
+
67
+ [GROUP.superMod]: makePermissionSet({
68
+ ...POSTER_FORUM_GRANTS,
69
+ canViewProfiles: true,
70
+ canViewMemberList: true,
71
+ canUsePrivateMessages: true,
72
+ canReportContent: true,
73
+ canAccessModCp: true,
74
+ isSuperModerator: true,
75
+ }),
76
+
77
+ [GROUP.admin]: makePermissionSet({
78
+ ...POSTER_FORUM_GRANTS,
79
+ canViewProfiles: true,
80
+ canViewMemberList: true,
81
+ canUsePrivateMessages: true,
82
+ canAccessModCp: true,
83
+ canAccessAdminCp: true,
84
+ isAdministrator: true,
85
+ }),
86
+ }
87
+
88
+ export const FORUM = {
89
+ public: 100,
90
+ publicSub: 101,
91
+ private: 200,
92
+ password: 300,
93
+ } as const
94
+
95
+ const CHAINS: Record<number, number[]> = {
96
+ [FORUM.public]: [FORUM.public],
97
+ [FORUM.publicSub]: [FORUM.publicSub, FORUM.public],
98
+ [FORUM.private]: [FORUM.private],
99
+ [FORUM.password]: [FORUM.password],
100
+ }
101
+
102
+ const OVERRIDES: ForumOverride[] = [
103
+ ...[GROUP.guest, GROUP.registered, GROUP.veterans].map((groupId) => ({
104
+ forumId: FORUM.private,
105
+ groupId,
106
+ overrides: {
107
+ canView: false,
108
+ canViewThreads: false,
109
+ canSearch: false,
110
+ canPostThreads: false,
111
+ canPostReplies: false,
112
+ },
113
+ })),
114
+
115
+ {
116
+ forumId: FORUM.publicSub,
117
+ groupId: GROUP.registered,
118
+ overrides: { canPostThreads: false, canPostReplies: false },
119
+ },
120
+ {
121
+ forumId: FORUM.publicSub,
122
+ groupId: GROUP.veterans,
123
+ overrides: { canPostThreads: false, canPostReplies: false },
124
+ },
125
+ ]
126
+
127
+ export class MemoryAuthorizationSource implements AuthorizationSource {
128
+ async groupDefaults(groupIds: readonly number[]): Promise<GroupDefaults[]> {
129
+ return groupIds
130
+ .filter((id) => id in GROUP_DEFAULTS)
131
+ .map((groupId) => ({ groupId, permissions: GROUP_DEFAULTS[groupId]! }))
132
+ }
133
+
134
+ async ancestorChain(forumId: number): Promise<number[]> {
135
+ return CHAINS[forumId] ?? []
136
+ }
137
+
138
+ async forumOverrides(
139
+ forumIds: readonly number[],
140
+ groupIds: readonly number[],
141
+ ): Promise<ForumOverride[]> {
142
+ const fset = new Set(forumIds)
143
+ const gset = new Set(groupIds)
144
+ return OVERRIDES.filter((o) => fset.has(o.forumId) && gset.has(o.groupId))
145
+ }
146
+
147
+ async allForumIds(): Promise<number[]> {
148
+ return [FORUM.public, FORUM.publicSub, FORUM.private, FORUM.password]
149
+ }
150
+
151
+ async allAncestorChains(): Promise<ReadonlyMap<number, readonly number[]>> {
152
+ const ids = await this.allForumIds()
153
+ return new Map(ids.map((id) => [id, CHAINS[id] ?? []]))
154
+ }
155
+
156
+ async moderatorAppointments(userId: number | null): Promise<readonly ModeratorAppointment[]> {
157
+ if (userId !== ACTORS.forumModerator.userId) return []
158
+ return [
159
+ {
160
+ forumId: FORUM.public,
161
+ cascadeToSubforums: true,
162
+ canApproveContent: true,
163
+ canEditPosts: true,
164
+ canSoftDeletePosts: true,
165
+ canRestorePosts: true,
166
+ canOpenCloseThreads: true,
167
+ canStickThreads: true,
168
+ canMoveThreads: true,
169
+ canMergeThreads: true,
170
+ canSplitThreads: true,
171
+ },
172
+ ]
173
+ }
174
+ }
175
+
176
+ function actor(
177
+ partial: Pick<Actor, 'userId' | 'groupIds' | 'state'> & Partial<Pick<Actor, 'primaryGroupId'>>,
178
+ ): Actor {
179
+ return {
180
+ userId: partial.userId,
181
+ groupIds: partial.groupIds,
182
+ primaryGroupId: partial.primaryGroupId ?? partial.groupIds[0] ?? null,
183
+ state: partial.state,
184
+ global: combineForActor(partial.groupIds),
185
+ permissionVersion: 1,
186
+ }
187
+ }
188
+
189
+ function combineForActor(groupIds: readonly number[]): PermissionSet {
190
+ const sets = groupIds.filter((id) => id in GROUP_DEFAULTS).map((id) => GROUP_DEFAULTS[id]!)
191
+ return combinePermissionSets(sets)
192
+ }
193
+
194
+ export const ACTORS = {
195
+ guest: actor({ userId: null, groupIds: [GROUP.guest], state: 'guest' }),
196
+ registered: actor({ userId: 10, groupIds: [GROUP.registered], state: 'active' }),
197
+ secondary: actor({
198
+ userId: 11,
199
+ groupIds: [GROUP.registered, GROUP.veterans],
200
+ state: 'active',
201
+ }),
202
+ forumModerator: actor({
203
+ userId: 12,
204
+ groupIds: [GROUP.registered],
205
+ state: 'active',
206
+ }),
207
+ superModerator: actor({
208
+ userId: 13,
209
+ groupIds: [GROUP.registered, GROUP.superMod],
210
+ state: 'active',
211
+ }),
212
+ administrator: actor({
213
+ userId: 14,
214
+ groupIds: [GROUP.registered, GROUP.admin],
215
+ state: 'active',
216
+ }),
217
+ banned: actor({ userId: 15, groupIds: [GROUP.registered], state: 'banned' }),
218
+ awaiting: actor({
219
+ userId: 16,
220
+ groupIds: [GROUP.registered],
221
+ state: 'awaiting_activation',
222
+ }),
223
+ } as const
224
+
225
+ export type ActorName = keyof typeof ACTORS
226
+ export type ForumName = keyof typeof FORUM
package/src/index.ts ADDED
@@ -0,0 +1,38 @@
1
+ export { type ContentScope, PUBLIC_CONTENT } from '@meith/core'
2
+
3
+ export type { AuthorizerOptions, BypassEvent } from './authorizer'
4
+ export { Authorizer } from './authorizer'
5
+ export { combineGroupValue, combinePermissionSets } from './combine'
6
+ export {
7
+ buildPermissionMatrix,
8
+ type CopyChange,
9
+ type CopyPlan,
10
+ type MatrixCell,
11
+ type MatrixInput,
12
+ type MatrixRow,
13
+ planCopyToDescendants,
14
+ readMatrixCell,
15
+ } from './matrix-editor'
16
+ export type { MemoryAppointment, MemoryBoard } from './memory-source'
17
+ export { InMemoryAuthorizationSource } from './memory-source'
18
+ export { forumSubset, indexOverrides, resolveForumMatrix } from './resolve'
19
+ export type {
20
+ Action,
21
+ Actor,
22
+ ActorSource,
23
+ ActorState,
24
+ AuthorizationSource,
25
+ ContentVisibility,
26
+ ForumOverride,
27
+ GroupDefaults,
28
+ ModeratedTarget,
29
+ NumericGlobalPermission,
30
+ Target,
31
+ Visible,
32
+ } from './types'
33
+ export {
34
+ hasAnyModeratorRight,
35
+ type ModeratorAppointment,
36
+ type ModeratorRights,
37
+ NO_MODERATOR_RIGHTS,
38
+ } from './types'
@@ -0,0 +1,145 @@
1
+ import { FORUM_PERMISSION_FIELDS, type ForumPermissions, type PermissionField } from '@meith/core'
2
+
3
+ import { resolveForumMatrix } from './resolve'
4
+ import type { ForumOverride, GroupDefaults } from './types'
5
+
6
+ export interface MatrixCell {
7
+ readonly key: string
8
+ readonly description: string
9
+ readonly kind: PermissionField['kind']
10
+ readonly stored: boolean | number | null
11
+ readonly control: string
12
+ readonly effective: boolean | number
13
+ readonly inheritedFrom: number | null
14
+ }
15
+
16
+ export interface MatrixRow {
17
+ readonly groupId: number
18
+ readonly groupTitle: string
19
+ readonly cells: readonly MatrixCell[]
20
+ }
21
+
22
+ export interface MatrixInput {
23
+ readonly chain: readonly number[]
24
+ readonly groups: readonly (GroupDefaults & { readonly title: string })[]
25
+ readonly overrides: readonly ForumOverride[]
26
+ }
27
+
28
+ function index(overrides: readonly ForumOverride[]): Map<string, ForumOverride> {
29
+ const map = new Map<string, ForumOverride>()
30
+ for (const override of overrides) map.set(`${override.forumId}:${override.groupId}`, override)
31
+ return map
32
+ }
33
+
34
+ function sourceOf(
35
+ chain: readonly number[],
36
+ groupId: number,
37
+ key: string,
38
+ byForumGroup: ReadonlyMap<string, ForumOverride>,
39
+ ): number | null {
40
+ for (const forumId of chain) {
41
+ const value = byForumGroup.get(`${forumId}:${groupId}`)?.overrides[
42
+ key as keyof ForumPermissions
43
+ ]
44
+ if (value !== undefined && value !== null) return forumId
45
+ }
46
+ return null
47
+ }
48
+
49
+ export function buildPermissionMatrix(input: MatrixInput): readonly MatrixRow[] {
50
+ const byForumGroup = index(input.overrides)
51
+ const forumId = input.chain[0]
52
+
53
+ return input.groups.map((group) => {
54
+ const resolved = resolveForumMatrix(input.chain, [group], byForumGroup)
55
+ const own = forumId === undefined ? undefined : byForumGroup.get(`${forumId}:${group.groupId}`)
56
+
57
+ return {
58
+ groupId: group.groupId,
59
+ groupTitle: group.title,
60
+ cells: FORUM_PERMISSION_FIELDS.map((field) => {
61
+ const stored = own?.overrides[field.key as keyof ForumPermissions]
62
+ const source = sourceOf(input.chain, group.groupId, field.key, byForumGroup)
63
+ const storedOrNull = stored === undefined ? null : stored
64
+
65
+ return {
66
+ key: field.key,
67
+ description: field.description,
68
+ kind: field.kind,
69
+ stored: storedOrNull,
70
+ control: matrixCellValue({ kind: field.kind, stored: storedOrNull }),
71
+ effective: resolved[field.key as keyof ForumPermissions] as boolean | number,
72
+ inheritedFrom: source === null || source === forumId ? null : source,
73
+ }
74
+ }),
75
+ }
76
+ })
77
+ }
78
+
79
+ export interface CopyChange {
80
+ readonly forumId: number
81
+ readonly groupId: number
82
+ readonly key: string
83
+ readonly from: boolean | number | null
84
+ readonly to: boolean | number | null
85
+ }
86
+
87
+ export interface CopyPlan {
88
+ readonly changes: readonly CopyChange[]
89
+ readonly unchanged: readonly number[]
90
+ }
91
+
92
+ export function planCopyToDescendants(input: {
93
+ readonly sourceForumId: number
94
+ readonly descendantIds: readonly number[]
95
+ readonly groupIds: readonly number[]
96
+ readonly overrides: readonly ForumOverride[]
97
+ }): CopyPlan {
98
+ const byForumGroup = index(input.overrides)
99
+ const changes: CopyChange[] = []
100
+ const touched = new Set<number>()
101
+
102
+ for (const forumId of input.descendantIds) {
103
+ for (const groupId of input.groupIds) {
104
+ const source = byForumGroup.get(`${input.sourceForumId}:${groupId}`)?.overrides ?? {}
105
+ const target = byForumGroup.get(`${forumId}:${groupId}`)?.overrides ?? {}
106
+
107
+ for (const field of FORUM_PERMISSION_FIELDS) {
108
+ const key = field.key as keyof ForumPermissions
109
+ const from = (target[key] ?? null) as boolean | number | null
110
+ const to = (source[key] ?? null) as boolean | number | null
111
+
112
+ if (Object.is(from, to)) continue
113
+ changes.push({ forumId, groupId, key: field.key, from, to })
114
+ touched.add(forumId)
115
+ }
116
+ }
117
+ }
118
+
119
+ return {
120
+ changes,
121
+ unchanged: input.descendantIds.filter((id) => !touched.has(id)),
122
+ }
123
+ }
124
+
125
+ export function readMatrixCell(
126
+ field: PermissionField,
127
+ raw: string | undefined,
128
+ ): boolean | number | null {
129
+ if (raw === undefined || raw === '' || raw === 'inherit') return null
130
+
131
+ if (field.kind === 'boolean' || field.kind === 'negative') {
132
+ if (raw === '1' || raw === 'grant') return true
133
+ if (raw === '0' || raw === 'deny') return false
134
+ return null
135
+ }
136
+
137
+ const value = Number(raw)
138
+ return Number.isSafeInteger(value) && value >= 0 ? value : null
139
+ }
140
+
141
+ export function matrixCellValue(cell: Pick<MatrixCell, 'kind' | 'stored'>): string {
142
+ if (cell.stored === null) return cell.kind === 'numeric' ? '' : 'inherit'
143
+ if (cell.kind === 'numeric') return String(cell.stored)
144
+ return cell.stored === true ? 'grant' : 'deny'
145
+ }
@@ -0,0 +1,131 @@
1
+ export const PERMISSION_ACTIONS = [
2
+ 'view',
3
+ 'postThread',
4
+ 'postReply',
5
+ 'editOwn',
6
+ 'editOthers',
7
+ 'deleteOwn',
8
+ 'softDelete',
9
+ 'viewUnapproved',
10
+ 'viewDeleted',
11
+ 'approve',
12
+ 'lock',
13
+ 'stick',
14
+ 'move',
15
+ 'deleteThread',
16
+ 'merge',
17
+ 'split',
18
+ 'upload',
19
+ 'download',
20
+ 'search',
21
+ 'subscribe',
22
+ ] as const
23
+
24
+ export type PermissionAction = (typeof PERMISSION_ACTIONS)[number]
25
+
26
+ const ALL: readonly PermissionAction[] = PERMISSION_ACTIONS
27
+
28
+ const MEMBER_PUBLIC: readonly PermissionAction[] = [
29
+ 'view',
30
+ 'postThread',
31
+ 'postReply',
32
+ 'editOwn',
33
+ 'deleteOwn',
34
+ 'upload',
35
+ 'download',
36
+ 'search',
37
+ 'subscribe',
38
+ ]
39
+
40
+ const MEMBER_READONLY: readonly PermissionAction[] = [
41
+ 'view',
42
+ 'editOwn',
43
+ 'deleteOwn',
44
+ 'upload',
45
+ 'download',
46
+ 'search',
47
+ 'subscribe',
48
+ ]
49
+
50
+ const MOD_PUBLIC: readonly PermissionAction[] = ALL
51
+
52
+ const MOD_READONLY: readonly PermissionAction[] = [
53
+ 'view',
54
+ 'editOwn',
55
+ 'editOthers',
56
+ 'deleteOwn',
57
+ 'softDelete',
58
+ 'viewUnapproved',
59
+ 'viewDeleted',
60
+ 'approve',
61
+ 'lock',
62
+ 'stick',
63
+ 'move',
64
+ 'deleteThread',
65
+ 'merge',
66
+ 'split',
67
+ 'upload',
68
+ 'download',
69
+ 'search',
70
+ 'subscribe',
71
+ ]
72
+
73
+ export type ExpectedMatrix = Record<string, Record<string, readonly PermissionAction[]>>
74
+
75
+ export const EXPECTED: ExpectedMatrix = {
76
+ guest: {
77
+ public: ['view', 'search'],
78
+ publicSub: ['view', 'search'],
79
+ private: [],
80
+ password: [],
81
+ },
82
+
83
+ registered: {
84
+ public: MEMBER_PUBLIC,
85
+ publicSub: MEMBER_READONLY,
86
+ private: [],
87
+ password: [],
88
+ },
89
+
90
+ secondary: {
91
+ public: MEMBER_PUBLIC,
92
+ publicSub: MEMBER_READONLY,
93
+ private: [],
94
+ password: [],
95
+ },
96
+
97
+ forumModerator: {
98
+ public: MOD_PUBLIC,
99
+ publicSub: MOD_READONLY,
100
+ private: [],
101
+ password: [],
102
+ },
103
+
104
+ superModerator: {
105
+ public: ALL,
106
+ publicSub: ALL,
107
+ private: ALL,
108
+ password: ALL,
109
+ },
110
+
111
+ administrator: {
112
+ public: ALL,
113
+ publicSub: ALL,
114
+ private: ALL,
115
+ password: ALL,
116
+ },
117
+
118
+ banned: {
119
+ public: [],
120
+ publicSub: [],
121
+ private: [],
122
+ password: [],
123
+ },
124
+
125
+ awaiting: {
126
+ public: ['view', 'search'],
127
+ publicSub: ['view', 'search'],
128
+ private: [],
129
+ password: [],
130
+ },
131
+ }
@@ -0,0 +1,68 @@
1
+ import type {
2
+ AuthorizationSource,
3
+ ForumOverride,
4
+ GroupDefaults,
5
+ ModeratorAppointment,
6
+ } from './types'
7
+
8
+ export interface MemoryBoard {
9
+ readonly groups: readonly GroupDefaults[]
10
+ readonly chains: Readonly<Record<number, readonly number[]>>
11
+ readonly overrides: readonly ForumOverride[]
12
+ readonly moderators?: readonly MemoryAppointment[]
13
+ }
14
+
15
+ export type MemoryAppointment = ModeratorAppointment & {
16
+ readonly userId?: number | null
17
+ readonly groupId?: number | null
18
+ }
19
+
20
+ export class InMemoryAuthorizationSource implements AuthorizationSource {
21
+ private readonly groupsById: Map<number, GroupDefaults>
22
+
23
+ constructor(private readonly board: MemoryBoard) {
24
+ this.groupsById = new Map(board.groups.map((g) => [g.groupId, g]))
25
+ }
26
+
27
+ async groupDefaults(groupIds: readonly number[]): Promise<readonly GroupDefaults[]> {
28
+ const out: GroupDefaults[] = []
29
+ for (const id of groupIds) {
30
+ const g = this.groupsById.get(id)
31
+ if (g) out.push(g)
32
+ }
33
+ return out
34
+ }
35
+
36
+ async ancestorChain(forumId: number): Promise<readonly number[]> {
37
+ return this.board.chains[forumId] ?? []
38
+ }
39
+
40
+ async forumOverrides(
41
+ forumIds: readonly number[],
42
+ groupIds: readonly number[],
43
+ ): Promise<readonly ForumOverride[]> {
44
+ const fset = new Set(forumIds)
45
+ const gset = new Set(groupIds)
46
+ return this.board.overrides.filter((o) => fset.has(o.forumId) && gset.has(o.groupId))
47
+ }
48
+
49
+ async allForumIds(): Promise<readonly number[]> {
50
+ return Object.keys(this.board.chains).map((k) => Number(k))
51
+ }
52
+
53
+ async allAncestorChains(): Promise<ReadonlyMap<number, readonly number[]>> {
54
+ return new Map(Object.entries(this.board.chains).map(([id, chain]) => [Number(id), chain]))
55
+ }
56
+
57
+ async moderatorAppointments(
58
+ userId: number | null,
59
+ groupIds: readonly number[],
60
+ ): Promise<readonly ModeratorAppointment[]> {
61
+ const groups = new Set(groupIds)
62
+ return (this.board.moderators ?? []).filter(
63
+ (row) =>
64
+ (row.userId != null && row.userId === userId) ||
65
+ (row.groupId != null && groups.has(row.groupId)),
66
+ )
67
+ }
68
+ }
package/src/resolve.ts ADDED
@@ -0,0 +1,45 @@
1
+ import { FORUM_PERMISSION_FIELDS, type ForumPermissions, type PermissionSet } from '@meith/core'
2
+
3
+ import { combineGroupValue } from './combine'
4
+ import type { ForumOverride, GroupDefaults } from './types'
5
+
6
+ export function resolveForumMatrix(
7
+ chain: readonly number[],
8
+ groups: readonly GroupDefaults[],
9
+ overridesByForumGroup: ReadonlyMap<string, ForumOverride>,
10
+ ): ForumPermissions {
11
+ const out: Record<string, boolean | number> = {}
12
+
13
+ for (const field of FORUM_PERMISSION_FIELDS) {
14
+ const key = field.key
15
+
16
+ const perGroup: (boolean | number)[] = groups.map((group) => {
17
+ for (const forumId of chain) {
18
+ const override = overridesByForumGroup.get(`${forumId}:${group.groupId}`)
19
+ const value = override?.overrides[key as keyof ForumPermissions]
20
+ if (value !== undefined && value !== null) {
21
+ return value as boolean | number
22
+ }
23
+ }
24
+ return (group.permissions as Record<string, boolean | number>)[key]!
25
+ })
26
+
27
+ out[key] = combineGroupValue(field.kind, perGroup)
28
+ }
29
+
30
+ return out as ForumPermissions
31
+ }
32
+
33
+ export function indexOverrides(overrides: readonly ForumOverride[]): Map<string, ForumOverride> {
34
+ const map = new Map<string, ForumOverride>()
35
+ for (const o of overrides) map.set(`${o.forumId}:${o.groupId}`, o)
36
+ return map
37
+ }
38
+
39
+ export function forumSubset(set: PermissionSet): ForumPermissions {
40
+ const out: Record<string, boolean | number> = {}
41
+ for (const field of FORUM_PERMISSION_FIELDS) {
42
+ out[field.key] = (set as Record<string, boolean | number>)[field.key]!
43
+ }
44
+ return out as ForumPermissions
45
+ }