@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/LICENSE.md +165 -0
- package/package.json +24 -0
- package/src/authorizer.ts +569 -0
- package/src/combine.ts +43 -0
- package/src/fixture.ts +226 -0
- package/src/index.ts +38 -0
- package/src/matrix-editor.ts +145 -0
- package/src/matrix.fixture.ts +131 -0
- package/src/memory-source.ts +68 -0
- package/src/resolve.ts +45 -0
- package/src/types.ts +158 -0
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
|
+
}
|