Ozone instance-wide and user-specific settings ()

*  Settings endpoints are working

* 🧹 Rename file

*  Replace ad-hoc manage roles to match team member roles

* ♻️ Refactor role names

*  Polish up

*  Move to using id for pagination

* 📝 Add changeset

*  Update snapshots

*  Change column order in setting table index and add did in all queries
This commit is contained in:
Foysal Ahamed 2024-11-07 22:43:30 +01:00 committed by GitHub
parent 839202a3d2
commit c4b5e53957
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
35 changed files with 2423 additions and 1 deletions

@ -0,0 +1,6 @@
---
"@atproto/ozone": patch
"@atproto/api": patch
---
Add user specific and instance-wide settings api for ozone

@ -0,0 +1,63 @@
{
"lexicon": 1,
"id": "tools.ozone.setting.defs",
"defs": {
"option": {
"type": "object",
"required": [
"key",
"value",
"did",
"scope",
"createdBy",
"lastUpdatedBy"
],
"properties": {
"key": {
"type": "string",
"format": "nsid"
},
"did": {
"type": "string",
"format": "did"
},
"value": {
"type": "unknown"
},
"description": {
"type": "string",
"maxGraphemes": 1024,
"maxLength": 10240
},
"createdAt": {
"type": "string",
"format": "datetime"
},
"updatedAt": {
"type": "string",
"format": "datetime"
},
"managerRole": {
"type": "string",
"knownValues": [
"tools.ozone.team.defs#roleModerator",
"tools.ozone.team.defs#roleTriage",
"tools.ozone.team.defs#roleAdmin"
]
},
"scope": {
"type": "string",
"knownValues": ["instance", "personal"]
},
"createdBy": {
"type": "string",
"format": "did"
},
"lastUpdatedBy": {
"type": "string",
"format": "did"
}
}
}
}
}

@ -0,0 +1,61 @@
{
"lexicon": 1,
"id": "tools.ozone.setting.listOptions",
"defs": {
"main": {
"type": "query",
"description": "List settings with optional filtering",
"parameters": {
"type": "params",
"properties": {
"limit": {
"type": "integer",
"minimum": 1,
"maximum": 100,
"default": 50
},
"cursor": {
"type": "string"
},
"scope": {
"type": "string",
"knownValues": ["instance", "personal"],
"default": "instance"
},
"prefix": {
"type": "string",
"description": "Filter keys by prefix"
},
"keys": {
"type": "array",
"maxLength": 100,
"items": {
"type": "string",
"format": "nsid"
},
"description": "Filter for only the specified keys. Ignored if prefix is provided"
}
}
},
"output": {
"encoding": "application/json",
"schema": {
"type": "object",
"required": ["options"],
"properties": {
"cursor": {
"type": "string"
},
"options": {
"type": "array",
"items": {
"type": "ref",
"ref": "tools.ozone.setting.defs#option"
}
}
}
}
}
}
}
}

@ -0,0 +1,39 @@
{
"lexicon": 1,
"id": "tools.ozone.setting.removeOptions",
"defs": {
"main": {
"type": "procedure",
"description": "Delete settings by key",
"input": {
"encoding": "application/json",
"schema": {
"type": "object",
"required": ["keys", "scope"],
"properties": {
"keys": {
"type": "array",
"minLength": 1,
"maxLength": 200,
"items": {
"type": "string",
"format": "nsid"
}
},
"scope": {
"type": "string",
"knownValues": ["instance", "personal"]
}
}
}
},
"output": {
"encoding": "application/json",
"schema": {
"type": "object",
"properties": {}
}
}
}
}
}

@ -0,0 +1,55 @@
{
"lexicon": 1,
"id": "tools.ozone.setting.upsertOption",
"defs": {
"main": {
"type": "procedure",
"description": "Create or update setting option",
"input": {
"encoding": "application/json",
"schema": {
"type": "object",
"required": ["key", "scope", "value"],
"properties": {
"key": {
"type": "string",
"format": "nsid"
},
"scope": {
"type": "string",
"knownValues": ["instance", "personal"]
},
"value": {
"type": "unknown"
},
"description": {
"type": "string",
"maxLength": 2000
},
"managerRole": {
"type": "string",
"knownValues": [
"tools.ozone.team.defs#roleModerator",
"tools.ozone.team.defs#roleTriage",
"tools.ozone.team.defs#roleAdmin"
]
}
}
}
},
"output": {
"encoding": "application/json",
"schema": {
"type": "object",
"required": ["option"],
"properties": {
"option": {
"type": "ref",
"ref": "tools.ozone.setting.defs#option"
}
}
}
}
}
}
}

@ -214,6 +214,10 @@ import * as ToolsOzoneSetDeleteValues from './types/tools/ozone/set/deleteValues
import * as ToolsOzoneSetGetValues from './types/tools/ozone/set/getValues'
import * as ToolsOzoneSetQuerySets from './types/tools/ozone/set/querySets'
import * as ToolsOzoneSetUpsertSet from './types/tools/ozone/set/upsertSet'
import * as ToolsOzoneSettingDefs from './types/tools/ozone/setting/defs'
import * as ToolsOzoneSettingListOptions from './types/tools/ozone/setting/listOptions'
import * as ToolsOzoneSettingRemoveOptions from './types/tools/ozone/setting/removeOptions'
import * as ToolsOzoneSettingUpsertOption from './types/tools/ozone/setting/upsertOption'
import * as ToolsOzoneSignatureDefs from './types/tools/ozone/signature/defs'
import * as ToolsOzoneSignatureFindCorrelation from './types/tools/ozone/signature/findCorrelation'
import * as ToolsOzoneSignatureFindRelatedAccounts from './types/tools/ozone/signature/findRelatedAccounts'
@ -434,6 +438,10 @@ export * as ToolsOzoneSetDeleteValues from './types/tools/ozone/set/deleteValues
export * as ToolsOzoneSetGetValues from './types/tools/ozone/set/getValues'
export * as ToolsOzoneSetQuerySets from './types/tools/ozone/set/querySets'
export * as ToolsOzoneSetUpsertSet from './types/tools/ozone/set/upsertSet'
export * as ToolsOzoneSettingDefs from './types/tools/ozone/setting/defs'
export * as ToolsOzoneSettingListOptions from './types/tools/ozone/setting/listOptions'
export * as ToolsOzoneSettingRemoveOptions from './types/tools/ozone/setting/removeOptions'
export * as ToolsOzoneSettingUpsertOption from './types/tools/ozone/setting/upsertOption'
export * as ToolsOzoneSignatureDefs from './types/tools/ozone/signature/defs'
export * as ToolsOzoneSignatureFindCorrelation from './types/tools/ozone/signature/findCorrelation'
export * as ToolsOzoneSignatureFindRelatedAccounts from './types/tools/ozone/signature/findRelatedAccounts'
@ -3433,6 +3441,7 @@ export class ToolsOzoneNS {
moderation: ToolsOzoneModerationNS
server: ToolsOzoneServerNS
set: ToolsOzoneSetNS
setting: ToolsOzoneSettingNS
signature: ToolsOzoneSignatureNS
team: ToolsOzoneTeamNS
@ -3442,6 +3451,7 @@ export class ToolsOzoneNS {
this.moderation = new ToolsOzoneModerationNS(client)
this.server = new ToolsOzoneServerNS(client)
this.set = new ToolsOzoneSetNS(client)
this.setting = new ToolsOzoneSettingNS(client)
this.signature = new ToolsOzoneSignatureNS(client)
this.team = new ToolsOzoneTeamNS(client)
}
@ -3701,6 +3711,50 @@ export class ToolsOzoneSetNS {
}
}
export class ToolsOzoneSettingNS {
_client: XrpcClient
constructor(client: XrpcClient) {
this._client = client
}
listOptions(
params?: ToolsOzoneSettingListOptions.QueryParams,
opts?: ToolsOzoneSettingListOptions.CallOptions,
): Promise<ToolsOzoneSettingListOptions.Response> {
return this._client.call(
'tools.ozone.setting.listOptions',
params,
undefined,
opts,
)
}
removeOptions(
data?: ToolsOzoneSettingRemoveOptions.InputSchema,
opts?: ToolsOzoneSettingRemoveOptions.CallOptions,
): Promise<ToolsOzoneSettingRemoveOptions.Response> {
return this._client.call(
'tools.ozone.setting.removeOptions',
opts?.qp,
data,
opts,
)
}
upsertOption(
data?: ToolsOzoneSettingUpsertOption.InputSchema,
opts?: ToolsOzoneSettingUpsertOption.CallOptions,
): Promise<ToolsOzoneSettingUpsertOption.Response> {
return this._client.call(
'tools.ozone.setting.upsertOption',
opts?.qp,
data,
opts,
)
}
}
export class ToolsOzoneSignatureNS {
_client: XrpcClient

@ -12520,6 +12520,225 @@ export const schemaDict = {
},
},
},
ToolsOzoneSettingDefs: {
lexicon: 1,
id: 'tools.ozone.setting.defs',
defs: {
option: {
type: 'object',
required: [
'key',
'value',
'did',
'scope',
'createdBy',
'lastUpdatedBy',
],
properties: {
key: {
type: 'string',
format: 'nsid',
},
did: {
type: 'string',
format: 'did',
},
value: {
type: 'unknown',
},
description: {
type: 'string',
maxGraphemes: 1024,
maxLength: 10240,
},
createdAt: {
type: 'string',
format: 'datetime',
},
updatedAt: {
type: 'string',
format: 'datetime',
},
managerRole: {
type: 'string',
knownValues: [
'tools.ozone.team.defs#roleModerator',
'tools.ozone.team.defs#roleTriage',
'tools.ozone.team.defs#roleAdmin',
],
},
scope: {
type: 'string',
knownValues: ['instance', 'personal'],
},
createdBy: {
type: 'string',
format: 'did',
},
lastUpdatedBy: {
type: 'string',
format: 'did',
},
},
},
},
},
ToolsOzoneSettingListOptions: {
lexicon: 1,
id: 'tools.ozone.setting.listOptions',
defs: {
main: {
type: 'query',
description: 'List settings with optional filtering',
parameters: {
type: 'params',
properties: {
limit: {
type: 'integer',
minimum: 1,
maximum: 100,
default: 50,
},
cursor: {
type: 'string',
},
scope: {
type: 'string',
knownValues: ['instance', 'personal'],
default: 'instance',
},
prefix: {
type: 'string',
description: 'Filter keys by prefix',
},
keys: {
type: 'array',
maxLength: 100,
items: {
type: 'string',
format: 'nsid',
},
description:
'Filter for only the specified keys. Ignored if prefix is provided',
},
},
},
output: {
encoding: 'application/json',
schema: {
type: 'object',
required: ['options'],
properties: {
cursor: {
type: 'string',
},
options: {
type: 'array',
items: {
type: 'ref',
ref: 'lex:tools.ozone.setting.defs#option',
},
},
},
},
},
},
},
},
ToolsOzoneSettingRemoveOptions: {
lexicon: 1,
id: 'tools.ozone.setting.removeOptions',
defs: {
main: {
type: 'procedure',
description: 'Delete settings by key',
input: {
encoding: 'application/json',
schema: {
type: 'object',
required: ['keys', 'scope'],
properties: {
keys: {
type: 'array',
minLength: 1,
maxLength: 200,
items: {
type: 'string',
format: 'nsid',
},
},
scope: {
type: 'string',
knownValues: ['instance', 'personal'],
},
},
},
},
output: {
encoding: 'application/json',
schema: {
type: 'object',
properties: {},
},
},
},
},
},
ToolsOzoneSettingUpsertOption: {
lexicon: 1,
id: 'tools.ozone.setting.upsertOption',
defs: {
main: {
type: 'procedure',
description: 'Create or update setting option',
input: {
encoding: 'application/json',
schema: {
type: 'object',
required: ['key', 'scope', 'value'],
properties: {
key: {
type: 'string',
format: 'nsid',
},
scope: {
type: 'string',
knownValues: ['instance', 'personal'],
},
value: {
type: 'unknown',
},
description: {
type: 'string',
maxLength: 2000,
},
managerRole: {
type: 'string',
knownValues: [
'tools.ozone.team.defs#roleModerator',
'tools.ozone.team.defs#roleTriage',
'tools.ozone.team.defs#roleAdmin',
],
},
},
},
},
output: {
encoding: 'application/json',
schema: {
type: 'object',
required: ['option'],
properties: {
option: {
type: 'ref',
ref: 'lex:tools.ozone.setting.defs#option',
},
},
},
},
},
},
},
ToolsOzoneSignatureDefs: {
lexicon: 1,
id: 'tools.ozone.signature.defs',
@ -13153,6 +13372,10 @@ export const ids = {
ToolsOzoneSetGetValues: 'tools.ozone.set.getValues',
ToolsOzoneSetQuerySets: 'tools.ozone.set.querySets',
ToolsOzoneSetUpsertSet: 'tools.ozone.set.upsertSet',
ToolsOzoneSettingDefs: 'tools.ozone.setting.defs',
ToolsOzoneSettingListOptions: 'tools.ozone.setting.listOptions',
ToolsOzoneSettingRemoveOptions: 'tools.ozone.setting.removeOptions',
ToolsOzoneSettingUpsertOption: 'tools.ozone.setting.upsertOption',
ToolsOzoneSignatureDefs: 'tools.ozone.signature.defs',
ToolsOzoneSignatureFindCorrelation: 'tools.ozone.signature.findCorrelation',
ToolsOzoneSignatureFindRelatedAccounts:

@ -0,0 +1,37 @@
/**
* GENERATED CODE - DO NOT MODIFY
*/
import { ValidationResult, BlobRef } from '@atproto/lexicon'
import { isObj, hasProp } from '../../../../util'
import { lexicons } from '../../../../lexicons'
import { CID } from 'multiformats/cid'
export interface Option {
key: string
did: string
value: {}
description?: string
createdAt?: string
updatedAt?: string
managerRole?:
| 'tools.ozone.team.defs#roleModerator'
| 'tools.ozone.team.defs#roleTriage'
| 'tools.ozone.team.defs#roleAdmin'
| (string & {})
scope: 'instance' | 'personal' | (string & {})
createdBy: string
lastUpdatedBy: string
[k: string]: unknown
}
export function isOption(v: unknown): v is Option {
return (
isObj(v) &&
hasProp(v, '$type') &&
v.$type === 'tools.ozone.setting.defs#option'
)
}
export function validateOption(v: unknown): ValidationResult {
return lexicons.validate('tools.ozone.setting.defs#option', v)
}

@ -0,0 +1,42 @@
/**
* GENERATED CODE - DO NOT MODIFY
*/
import { HeadersMap, XRPCError } from '@atproto/xrpc'
import { ValidationResult, BlobRef } from '@atproto/lexicon'
import { isObj, hasProp } from '../../../../util'
import { lexicons } from '../../../../lexicons'
import { CID } from 'multiformats/cid'
import * as ToolsOzoneSettingDefs from './defs'
export interface QueryParams {
limit?: number
cursor?: string
scope?: 'instance' | 'personal' | (string & {})
/** Filter keys by prefix */
prefix?: string
/** Filter for only the specified keys. Ignored if prefix is provided */
keys?: string[]
}
export type InputSchema = undefined
export interface OutputSchema {
cursor?: string
options: ToolsOzoneSettingDefs.Option[]
[k: string]: unknown
}
export interface CallOptions {
signal?: AbortSignal
headers?: HeadersMap
}
export interface Response {
success: boolean
headers: HeadersMap
data: OutputSchema
}
export function toKnownErr(e: any) {
return e
}

@ -0,0 +1,37 @@
/**
* GENERATED CODE - DO NOT MODIFY
*/
import { HeadersMap, XRPCError } from '@atproto/xrpc'
import { ValidationResult, BlobRef } from '@atproto/lexicon'
import { isObj, hasProp } from '../../../../util'
import { lexicons } from '../../../../lexicons'
import { CID } from 'multiformats/cid'
export interface QueryParams {}
export interface InputSchema {
keys: string[]
scope: 'instance' | 'personal' | (string & {})
[k: string]: unknown
}
export interface OutputSchema {
[k: string]: unknown
}
export interface CallOptions {
signal?: AbortSignal
headers?: HeadersMap
qp?: QueryParams
encoding?: 'application/json'
}
export interface Response {
success: boolean
headers: HeadersMap
data: OutputSchema
}
export function toKnownErr(e: any) {
return e
}

@ -0,0 +1,46 @@
/**
* GENERATED CODE - DO NOT MODIFY
*/
import { HeadersMap, XRPCError } from '@atproto/xrpc'
import { ValidationResult, BlobRef } from '@atproto/lexicon'
import { isObj, hasProp } from '../../../../util'
import { lexicons } from '../../../../lexicons'
import { CID } from 'multiformats/cid'
import * as ToolsOzoneSettingDefs from './defs'
export interface QueryParams {}
export interface InputSchema {
key: string
scope: 'instance' | 'personal' | (string & {})
value: {}
description?: string
managerRole?:
| 'tools.ozone.team.defs#roleModerator'
| 'tools.ozone.team.defs#roleTriage'
| 'tools.ozone.team.defs#roleAdmin'
| (string & {})
[k: string]: unknown
}
export interface OutputSchema {
option: ToolsOzoneSettingDefs.Option
[k: string]: unknown
}
export interface CallOptions {
signal?: AbortSignal
headers?: HeadersMap
qp?: QueryParams
encoding?: 'application/json'
}
export interface Response {
success: boolean
headers: HeadersMap
data: OutputSchema
}
export function toKnownErr(e: any) {
return e
}

@ -30,6 +30,9 @@ import upsertSet from './set/upsertSet'
import setDeleteValues from './set/deleteValues'
import deleteSet from './set/deleteSet'
import getRepos from './moderation/getRepos'
import listOptions from './setting/listOptions'
import removeOptions from './setting/removeOptions'
import upsertOption from './setting/upsertOption'
export * as health from './health'
@ -66,5 +69,8 @@ export default function (server: Server, ctx: AppContext) {
upsertSet(server, ctx)
setDeleteValues(server, ctx)
deleteSet(server, ctx)
upsertOption(server, ctx)
listOptions(server, ctx)
removeOptions(server, ctx)
return server
}

@ -0,0 +1,44 @@
import { AuthRequiredError } from '@atproto/xrpc-server'
import { Server } from '../../lexicon'
import AppContext from '../../context'
export default function (server: Server, ctx: AppContext) {
server.tools.ozone.setting.listOptions({
auth: ctx.authVerifier.modOrAdminToken,
handler: async ({ params, auth }) => {
const access = auth.credentials
const db = ctx.db
const { prefix, scope, keys, limit, cursor } = params
let did = ctx.cfg.service.did
if (scope === 'personal') {
if (access.type !== 'moderator') {
throw new AuthRequiredError(
'Must use moderator auth to get personal set details',
)
}
did = access.iss
}
const settingService = ctx.settingService(db)
const result = await settingService.query({
scope: scope === 'personal' ? 'personal' : 'instance',
did,
keys,
prefix,
limit,
cursor,
})
return {
encoding: 'application/json',
body: {
options: result.options.map((option) => settingService.view(option)),
cursor: result.cursor,
},
}
},
})
}

@ -0,0 +1,63 @@
import { AuthRequiredError } from '@atproto/xrpc-server'
import { Server } from '../../lexicon'
import AppContext from '../../context'
import { Member } from '../../db/schema/member'
export default function (server: Server, ctx: AppContext) {
server.tools.ozone.setting.removeOptions({
auth: ctx.authVerifier.modOrAdminToken,
handler: async ({ input, auth }) => {
const access = auth.credentials
const db = ctx.db
const { keys, scope } = input.body
let did = ctx.cfg.service.did
let managerRole: Member['role'][] = []
if (scope === 'personal') {
if (access.type !== 'moderator') {
throw new AuthRequiredError(
'Must use moderator auth to delete personal setting',
)
}
did = access.iss
}
// When attempting to delete an instance setting using admin_token will allow removing any setting
// otherwise, admins can remove settings that are manageable by all roles
// moderators can remove settings that are manageable by moderator and triage roles
// triage can remove settings that are manageable by triage role
if (scope === 'instance') {
managerRole = [
'tools.ozone.team.defs#roleModerator',
'tools.ozone.team.defs#roleTriage',
'tools.ozone.team.defs#roleAdmin',
]
if (access.type !== 'admin_token' && !access.isAdmin) {
if (access.isModerator) {
managerRole = [
'tools.ozone.team.defs#roleModerator',
'tools.ozone.team.defs#roleTriage',
]
} else if (access.isTriage) {
managerRole = ['tools.ozone.team.defs#roleTriage']
}
}
}
const settingService = ctx.settingService(db)
await settingService.removeOptions(keys, {
scope: scope === 'personal' ? 'personal' : 'instance',
managerRole,
did,
})
return {
encoding: 'application/json',
body: {},
}
},
})
}

@ -0,0 +1,142 @@
import { AuthRequiredError } from '@atproto/xrpc-server'
import { Server } from '../../lexicon'
import AppContext from '../../context'
import { AdminTokenOutput, ModeratorOutput } from '../../auth-verifier'
import { SettingService } from '../../setting/service'
import { Member } from '../../db/schema/member'
import { ToolsOzoneTeamDefs } from '@atproto/api'
import assert from 'node:assert'
export default function (server: Server, ctx: AppContext) {
server.tools.ozone.setting.upsertOption({
auth: ctx.authVerifier.modOrAdminToken,
handler: async ({ input, auth }) => {
const access = auth.credentials
const db = ctx.db
const { key, value, description, managerRole, scope } = input.body
const serviceDid = ctx.cfg.service.did
let ownerDid = serviceDid
if (scope === 'personal' && access.type !== 'moderator') {
throw new AuthRequiredError(
'Must use moderator auth to create or update a personal setting',
)
}
// if the caller is using moderator auth and storing personal setting
// use the caller's DID as the owner
if (scope === 'personal' && access.type === 'moderator') {
ownerDid = access.iss
}
const now = new Date()
const baseOption = {
key,
value,
did: ownerDid,
createdBy: ownerDid,
lastUpdatedBy: ownerDid,
description: description || '',
createdAt: now,
updatedAt: now,
}
const settingService = ctx.settingService(db)
if (scope === 'personal') {
await settingService.upsert({
...baseOption,
scope: 'personal',
managerRole: null,
})
} else {
const manageableRoles = getRolesForInstanceOption(access)
const existingSetting = await getExistingSetting(
settingService,
ownerDid,
key,
'instance',
)
if (
existingSetting?.managerRole &&
!manageableRoles.includes(existingSetting.managerRole)
) {
throw new AuthRequiredError(`Not permitted to update setting ${key}`)
}
await settingService.upsert({
...baseOption,
scope: 'instance',
managerRole: getManagerRole(managerRole),
})
}
const newOption = await getExistingSetting(
settingService,
ownerDid,
key,
scope,
)
assert(newOption, 'Failed to get the updated setting')
return {
encoding: 'application/json',
body: {
option: settingService.view(newOption),
},
}
},
})
}
const getExistingSetting = async (
settingService: SettingService,
did: string,
key: string,
scope: string,
) => {
const result = await settingService.query({
scope: scope === 'personal' ? 'personal' : 'instance',
keys: [key],
limit: 1,
did,
})
return result.options[0]
}
const getRolesForInstanceOption = (
access: AdminTokenOutput['credentials'] | ModeratorOutput['credentials'],
) => {
const fullPermission = [
ToolsOzoneTeamDefs.ROLEADMIN,
ToolsOzoneTeamDefs.ROLEMODERATOR,
ToolsOzoneTeamDefs.ROLETRIAGE,
]
if (access.type === 'admin_token') {
return fullPermission
}
if (access.isAdmin) {
return fullPermission
}
if (access.isModerator) {
return [ToolsOzoneTeamDefs.ROLEMODERATOR, ToolsOzoneTeamDefs.ROLETRIAGE]
}
return [ToolsOzoneTeamDefs.ROLETRIAGE]
}
const getManagerRole = (role?: string) => {
let managerRole: Member['role'] | null = null
if (role === ToolsOzoneTeamDefs.ROLEADMIN) {
managerRole = ToolsOzoneTeamDefs.ROLEADMIN
} else if (role === ToolsOzoneTeamDefs.ROLEMODERATOR) {
managerRole = ToolsOzoneTeamDefs.ROLEMODERATOR
} else if (role === ToolsOzoneTeamDefs.ROLETRIAGE) {
managerRole = ToolsOzoneTeamDefs.ROLETRIAGE
}
return managerRole
}

@ -27,6 +27,7 @@ import {
parseLabelerHeader,
} from './util'
import { SetService, SetServiceCreator } from './set/service'
import { SettingService, SettingServiceCreator } from './setting/service'
export type AppContextOptions = {
db: Database
@ -34,6 +35,7 @@ export type AppContextOptions = {
modService: ModerationServiceCreator
communicationTemplateService: CommunicationTemplateServiceCreator
setService: SetServiceCreator
settingService: SettingServiceCreator
teamService: TeamServiceCreator
appviewAgent: AtpAgent
pdsAgent: AtpAgent | undefined
@ -120,6 +122,7 @@ export class AppContext {
const communicationTemplateService = CommunicationTemplateService.creator()
const teamService = TeamService.creator()
const setService = SetService.creator()
const settingService = SettingService.creator()
const sequencer = new Sequencer(modService(db))
@ -137,6 +140,7 @@ export class AppContext {
communicationTemplateService,
teamService,
setService,
settingService,
appviewAgent,
pdsAgent,
chatAgent,
@ -190,6 +194,10 @@ export class AppContext {
return this.opts.setService
}
get settingService(): SettingServiceCreator {
return this.opts.settingService
}
get appviewAgent(): AtpAgent {
return this.opts.appviewAgent
}

@ -0,0 +1,27 @@
import { Kysely, sql } from 'kysely'
export async function up(db: Kysely<unknown>): Promise<void> {
await db.schema
.createTable('setting')
.addColumn('id', 'serial', (col) => col.primaryKey())
.addColumn('key', 'text', (col) => col.notNull())
.addColumn('did', 'text', (col) => col.notNull())
.addColumn('value', 'jsonb', (col) => col.notNull())
.addColumn('description', 'text')
.addColumn('createdAt', 'timestamptz', (col) =>
col.defaultTo(sql`now()`).notNull(),
)
.addColumn('updatedAt', 'timestamptz', (col) =>
col.defaultTo(sql`now()`).notNull(),
)
.addColumn('managerRole', 'text')
.addColumn('scope', 'text', (col) => col.notNull())
.addColumn('createdBy', 'text', (col) => col.notNull())
.addColumn('lastUpdatedBy', 'text', (col) => col.notNull())
.addUniqueConstraint('setting_did_scope_key_idx', ['did', 'scope', 'key'])
.execute()
}
export async function down(db: Kysely<unknown>): Promise<void> {
await db.schema.dropTable('setting').execute()
}

@ -15,3 +15,4 @@ export * as _20240903T205730722Z from './20240903T205730722Z-add-template-lang'
export * as _20240904T205730722Z from './20240904T205730722Z-add-subject-did-index'
export * as _20241001T205730722Z from './20241001T205730722Z-subject-status-review-state-index'
export * as _20241008T205730722Z from './20241008T205730722Z-sets'
export * as _20241018T205730722Z from './20241018T205730722Z-setting'

@ -9,6 +9,7 @@ import * as signingKey from './signing_key'
import * as communicationTemplate from './communication_template'
import * as set from './ozone_set'
import * as member from './member'
import * as setting from './setting'
export type DatabaseSchemaType = modEvent.PartialDB &
modSubjectStatus.PartialDB &
@ -19,7 +20,8 @@ export type DatabaseSchemaType = modEvent.PartialDB &
blobPushEvent.PartialDB &
communicationTemplate.PartialDB &
set.PartialDB &
member.PartialDB
member.PartialDB &
setting.PartialDB
export type DatabaseSchema = Kysely<DatabaseSchemaType>

@ -0,0 +1,24 @@
import { Generated, GeneratedAlways } from 'kysely'
import { Member } from './member'
export const settingTableName = 'setting'
export type SettingScope = 'personal' | 'instance'
export interface Setting {
id: GeneratedAlways<number>
key: string
value: Record<string, unknown>
managerRole: Member['role'] | null
description: string | null
did: string
scope: SettingScope
lastUpdatedBy: string
createdBy: string
createdAt: Generated<Date>
updatedAt: Generated<Date>
}
export type PartialDB = {
[settingTableName]: Setting
}

@ -180,6 +180,9 @@ import * as ToolsOzoneSetDeleteValues from './types/tools/ozone/set/deleteValues
import * as ToolsOzoneSetGetValues from './types/tools/ozone/set/getValues'
import * as ToolsOzoneSetQuerySets from './types/tools/ozone/set/querySets'
import * as ToolsOzoneSetUpsertSet from './types/tools/ozone/set/upsertSet'
import * as ToolsOzoneSettingListOptions from './types/tools/ozone/setting/listOptions'
import * as ToolsOzoneSettingRemoveOptions from './types/tools/ozone/setting/removeOptions'
import * as ToolsOzoneSettingUpsertOption from './types/tools/ozone/setting/upsertOption'
import * as ToolsOzoneSignatureFindCorrelation from './types/tools/ozone/signature/findCorrelation'
import * as ToolsOzoneSignatureFindRelatedAccounts from './types/tools/ozone/signature/findRelatedAccounts'
import * as ToolsOzoneSignatureSearchAccounts from './types/tools/ozone/signature/searchAccounts'
@ -2183,6 +2186,7 @@ export class ToolsOzoneNS {
moderation: ToolsOzoneModerationNS
server: ToolsOzoneServerNS
set: ToolsOzoneSetNS
setting: ToolsOzoneSettingNS
signature: ToolsOzoneSignatureNS
team: ToolsOzoneTeamNS
@ -2192,6 +2196,7 @@ export class ToolsOzoneNS {
this.moderation = new ToolsOzoneModerationNS(server)
this.server = new ToolsOzoneServerNS(server)
this.set = new ToolsOzoneSetNS(server)
this.setting = new ToolsOzoneSettingNS(server)
this.signature = new ToolsOzoneSignatureNS(server)
this.team = new ToolsOzoneTeamNS(server)
}
@ -2449,6 +2454,47 @@ export class ToolsOzoneSetNS {
}
}
export class ToolsOzoneSettingNS {
_server: Server
constructor(server: Server) {
this._server = server
}
listOptions<AV extends AuthVerifier>(
cfg: ConfigOf<
AV,
ToolsOzoneSettingListOptions.Handler<ExtractAuth<AV>>,
ToolsOzoneSettingListOptions.HandlerReqCtx<ExtractAuth<AV>>
>,
) {
const nsid = 'tools.ozone.setting.listOptions' // @ts-ignore
return this._server.xrpc.method(nsid, cfg)
}
removeOptions<AV extends AuthVerifier>(
cfg: ConfigOf<
AV,
ToolsOzoneSettingRemoveOptions.Handler<ExtractAuth<AV>>,
ToolsOzoneSettingRemoveOptions.HandlerReqCtx<ExtractAuth<AV>>
>,
) {
const nsid = 'tools.ozone.setting.removeOptions' // @ts-ignore
return this._server.xrpc.method(nsid, cfg)
}
upsertOption<AV extends AuthVerifier>(
cfg: ConfigOf<
AV,
ToolsOzoneSettingUpsertOption.Handler<ExtractAuth<AV>>,
ToolsOzoneSettingUpsertOption.HandlerReqCtx<ExtractAuth<AV>>
>,
) {
const nsid = 'tools.ozone.setting.upsertOption' // @ts-ignore
return this._server.xrpc.method(nsid, cfg)
}
}
export class ToolsOzoneSignatureNS {
_server: Server

@ -12520,6 +12520,225 @@ export const schemaDict = {
},
},
},
ToolsOzoneSettingDefs: {
lexicon: 1,
id: 'tools.ozone.setting.defs',
defs: {
option: {
type: 'object',
required: [
'key',
'value',
'did',
'scope',
'createdBy',
'lastUpdatedBy',
],
properties: {
key: {
type: 'string',
format: 'nsid',
},
did: {
type: 'string',
format: 'did',
},
value: {
type: 'unknown',
},
description: {
type: 'string',
maxGraphemes: 1024,
maxLength: 10240,
},
createdAt: {
type: 'string',
format: 'datetime',
},
updatedAt: {
type: 'string',
format: 'datetime',
},
managerRole: {
type: 'string',
knownValues: [
'tools.ozone.team.defs#roleModerator',
'tools.ozone.team.defs#roleTriage',
'tools.ozone.team.defs#roleAdmin',
],
},
scope: {
type: 'string',
knownValues: ['instance', 'personal'],
},
createdBy: {
type: 'string',
format: 'did',
},
lastUpdatedBy: {
type: 'string',
format: 'did',
},
},
},
},
},
ToolsOzoneSettingListOptions: {
lexicon: 1,
id: 'tools.ozone.setting.listOptions',
defs: {
main: {
type: 'query',
description: 'List settings with optional filtering',
parameters: {
type: 'params',
properties: {
limit: {
type: 'integer',
minimum: 1,
maximum: 100,
default: 50,
},
cursor: {
type: 'string',
},
scope: {
type: 'string',
knownValues: ['instance', 'personal'],
default: 'instance',
},
prefix: {
type: 'string',
description: 'Filter keys by prefix',
},
keys: {
type: 'array',
maxLength: 100,
items: {
type: 'string',
format: 'nsid',
},
description:
'Filter for only the specified keys. Ignored if prefix is provided',
},
},
},
output: {
encoding: 'application/json',
schema: {
type: 'object',
required: ['options'],
properties: {
cursor: {
type: 'string',
},
options: {
type: 'array',
items: {
type: 'ref',
ref: 'lex:tools.ozone.setting.defs#option',
},
},
},
},
},
},
},
},
ToolsOzoneSettingRemoveOptions: {
lexicon: 1,
id: 'tools.ozone.setting.removeOptions',
defs: {
main: {
type: 'procedure',
description: 'Delete settings by key',
input: {
encoding: 'application/json',
schema: {
type: 'object',
required: ['keys', 'scope'],
properties: {
keys: {
type: 'array',
minLength: 1,
maxLength: 200,
items: {
type: 'string',
format: 'nsid',
},
},
scope: {
type: 'string',
knownValues: ['instance', 'personal'],
},
},
},
},
output: {
encoding: 'application/json',
schema: {
type: 'object',
properties: {},
},
},
},
},
},
ToolsOzoneSettingUpsertOption: {
lexicon: 1,
id: 'tools.ozone.setting.upsertOption',
defs: {
main: {
type: 'procedure',
description: 'Create or update setting option',
input: {
encoding: 'application/json',
schema: {
type: 'object',
required: ['key', 'scope', 'value'],
properties: {
key: {
type: 'string',
format: 'nsid',
},
scope: {
type: 'string',
knownValues: ['instance', 'personal'],
},
value: {
type: 'unknown',
},
description: {
type: 'string',
maxLength: 2000,
},
managerRole: {
type: 'string',
knownValues: [
'tools.ozone.team.defs#roleModerator',
'tools.ozone.team.defs#roleTriage',
'tools.ozone.team.defs#roleAdmin',
],
},
},
},
},
output: {
encoding: 'application/json',
schema: {
type: 'object',
required: ['option'],
properties: {
option: {
type: 'ref',
ref: 'lex:tools.ozone.setting.defs#option',
},
},
},
},
},
},
},
ToolsOzoneSignatureDefs: {
lexicon: 1,
id: 'tools.ozone.signature.defs',
@ -13153,6 +13372,10 @@ export const ids = {
ToolsOzoneSetGetValues: 'tools.ozone.set.getValues',
ToolsOzoneSetQuerySets: 'tools.ozone.set.querySets',
ToolsOzoneSetUpsertSet: 'tools.ozone.set.upsertSet',
ToolsOzoneSettingDefs: 'tools.ozone.setting.defs',
ToolsOzoneSettingListOptions: 'tools.ozone.setting.listOptions',
ToolsOzoneSettingRemoveOptions: 'tools.ozone.setting.removeOptions',
ToolsOzoneSettingUpsertOption: 'tools.ozone.setting.upsertOption',
ToolsOzoneSignatureDefs: 'tools.ozone.signature.defs',
ToolsOzoneSignatureFindCorrelation: 'tools.ozone.signature.findCorrelation',
ToolsOzoneSignatureFindRelatedAccounts:

@ -0,0 +1,37 @@
/**
* GENERATED CODE - DO NOT MODIFY
*/
import { ValidationResult, BlobRef } from '@atproto/lexicon'
import { lexicons } from '../../../../lexicons'
import { isObj, hasProp } from '../../../../util'
import { CID } from 'multiformats/cid'
export interface Option {
key: string
did: string
value: {}
description?: string
createdAt?: string
updatedAt?: string
managerRole?:
| 'tools.ozone.team.defs#roleModerator'
| 'tools.ozone.team.defs#roleTriage'
| 'tools.ozone.team.defs#roleAdmin'
| (string & {})
scope: 'instance' | 'personal' | (string & {})
createdBy: string
lastUpdatedBy: string
[k: string]: unknown
}
export function isOption(v: unknown): v is Option {
return (
isObj(v) &&
hasProp(v, '$type') &&
v.$type === 'tools.ozone.setting.defs#option'
)
}
export function validateOption(v: unknown): ValidationResult {
return lexicons.validate('tools.ozone.setting.defs#option', v)
}

@ -0,0 +1,53 @@
/**
* GENERATED CODE - DO NOT MODIFY
*/
import express from 'express'
import { ValidationResult, BlobRef } from '@atproto/lexicon'
import { lexicons } from '../../../../lexicons'
import { isObj, hasProp } from '../../../../util'
import { CID } from 'multiformats/cid'
import { HandlerAuth, HandlerPipeThrough } from '@atproto/xrpc-server'
import * as ToolsOzoneSettingDefs from './defs'
export interface QueryParams {
limit: number
cursor?: string
scope: 'instance' | 'personal' | (string & {})
/** Filter keys by prefix */
prefix?: string
/** Filter for only the specified keys. Ignored if prefix is provided */
keys?: string[]
}
export type InputSchema = undefined
export interface OutputSchema {
cursor?: string
options: ToolsOzoneSettingDefs.Option[]
[k: string]: unknown
}
export type HandlerInput = undefined
export interface HandlerSuccess {
encoding: 'application/json'
body: OutputSchema
headers?: { [key: string]: string }
}
export interface HandlerError {
status: number
message?: string
}
export type HandlerOutput = HandlerError | HandlerSuccess | HandlerPipeThrough
export type HandlerReqCtx<HA extends HandlerAuth = never> = {
auth: HA
params: QueryParams
input: HandlerInput
req: express.Request
res: express.Response
}
export type Handler<HA extends HandlerAuth = never> = (
ctx: HandlerReqCtx<HA>,
) => Promise<HandlerOutput> | HandlerOutput

@ -0,0 +1,49 @@
/**
* GENERATED CODE - DO NOT MODIFY
*/
import express from 'express'
import { ValidationResult, BlobRef } from '@atproto/lexicon'
import { lexicons } from '../../../../lexicons'
import { isObj, hasProp } from '../../../../util'
import { CID } from 'multiformats/cid'
import { HandlerAuth, HandlerPipeThrough } from '@atproto/xrpc-server'
export interface QueryParams {}
export interface InputSchema {
keys: string[]
scope: 'instance' | 'personal' | (string & {})
[k: string]: unknown
}
export interface OutputSchema {
[k: string]: unknown
}
export interface HandlerInput {
encoding: 'application/json'
body: InputSchema
}
export interface HandlerSuccess {
encoding: 'application/json'
body: OutputSchema
headers?: { [key: string]: string }
}
export interface HandlerError {
status: number
message?: string
}
export type HandlerOutput = HandlerError | HandlerSuccess | HandlerPipeThrough
export type HandlerReqCtx<HA extends HandlerAuth = never> = {
auth: HA
params: QueryParams
input: HandlerInput
req: express.Request
res: express.Response
}
export type Handler<HA extends HandlerAuth = never> = (
ctx: HandlerReqCtx<HA>,
) => Promise<HandlerOutput> | HandlerOutput

@ -0,0 +1,58 @@
/**
* GENERATED CODE - DO NOT MODIFY
*/
import express from 'express'
import { ValidationResult, BlobRef } from '@atproto/lexicon'
import { lexicons } from '../../../../lexicons'
import { isObj, hasProp } from '../../../../util'
import { CID } from 'multiformats/cid'
import { HandlerAuth, HandlerPipeThrough } from '@atproto/xrpc-server'
import * as ToolsOzoneSettingDefs from './defs'
export interface QueryParams {}
export interface InputSchema {
key: string
scope: 'instance' | 'personal' | (string & {})
value: {}
description?: string
managerRole?:
| 'tools.ozone.team.defs#roleModerator'
| 'tools.ozone.team.defs#roleTriage'
| 'tools.ozone.team.defs#roleAdmin'
| (string & {})
[k: string]: unknown
}
export interface OutputSchema {
option: ToolsOzoneSettingDefs.Option
[k: string]: unknown
}
export interface HandlerInput {
encoding: 'application/json'
body: InputSchema
}
export interface HandlerSuccess {
encoding: 'application/json'
body: OutputSchema
headers?: { [key: string]: string }
}
export interface HandlerError {
status: number
message?: string
}
export type HandlerOutput = HandlerError | HandlerSuccess | HandlerPipeThrough
export type HandlerReqCtx<HA extends HandlerAuth = never> = {
auth: HA
params: QueryParams
input: HandlerInput
req: express.Request
res: express.Response
}
export type Handler<HA extends HandlerAuth = never> = (
ctx: HandlerReqCtx<HA>,
) => Promise<HandlerOutput> | HandlerOutput

@ -0,0 +1,148 @@
import Database from '../db'
import { Selectable } from 'kysely'
import { Option } from '../lexicon/types/tools/ozone/setting/defs'
import { Setting, SettingScope } from '../db/schema/setting'
import { Member } from '../db/schema/member'
import assert from 'node:assert'
import { InvalidRequestError } from '@atproto/xrpc-server'
export type SettingServiceCreator = (db: Database) => SettingService
export class SettingService {
constructor(public db: Database) {}
static creator() {
return (db: Database) => new SettingService(db)
}
async query({
limit = 100,
scope,
did,
cursor,
prefix,
keys,
}: {
limit: number
scope?: 'personal' | 'instance'
did?: string
cursor?: string
prefix?: string
keys?: string[]
}): Promise<{
options: Selectable<Setting>[]
cursor?: string
}> {
let builder = this.db.db.selectFrom('setting').selectAll()
if (prefix) {
builder = builder.where('key', 'like', `${prefix}%`)
} else if (keys?.length) {
builder = builder.where('key', 'in', keys)
}
if (scope) {
builder = builder.where('scope', '=', scope)
}
if (did) {
builder = builder.where('did', '=', did)
}
if (cursor) {
const cursorId = parseInt(cursor, 10)
if (isNaN(cursorId)) {
throw new InvalidRequestError('invalid cursor')
}
builder = builder.where('id', '<', cursorId)
}
const options = await builder.orderBy('id', 'desc').limit(limit).execute()
return {
options,
cursor: options[options.length - 1]?.id.toString(),
}
}
async upsert(
option: Omit<Setting, 'id' | 'createdAt' | 'updatedAt'> & {
createdAt: Date
updatedAt: Date
},
): Promise<void> {
await this.db.db
.insertInto('setting')
.values(option)
.onConflict((oc) => {
return oc.columns(['key', 'scope', 'did']).doUpdateSet({
value: option.value,
updatedAt: option.updatedAt,
description: option.description,
managerRole: option.managerRole,
lastUpdatedBy: option.lastUpdatedBy,
})
})
.execute()
}
async removeOptions(
keys: string[],
filters: {
did?: string
scope: SettingScope
managerRole: Member['role'][]
},
): Promise<void> {
if (!keys.length) return
if (filters.scope === 'personal') {
assert(filters.did, 'did is required for personal scope')
}
let qb = this.db.db
.deleteFrom('setting')
.where('key', 'in', keys)
.where('scope', '=', filters.scope)
if (filters.managerRole.length) {
qb = qb.where('managerRole', 'in', filters.managerRole)
} else {
qb = qb.where('managerRole', 'is', null)
}
if (filters.did) {
qb = qb.where('did', '=', filters.did)
}
await qb.execute()
}
view(setting: Selectable<Setting>): Option {
const {
key,
value,
did,
description,
createdAt,
createdBy,
updatedAt,
lastUpdatedBy,
managerRole,
scope,
} = setting
return {
key,
value,
did,
scope,
createdBy,
lastUpdatedBy,
managerRole: managerRole || undefined,
description: description || undefined,
createdAt: createdAt.toISOString(),
updatedAt: updatedAt.toISOString(),
}
}
}

@ -0,0 +1,52 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`ozone-settings listOptions returns all personal settings 1`] = `
Array [
Object {
"createdAt": "1970-01-01T00:00:00.000Z",
"createdBy": "user(1)",
"description": "List of external labelers that will be plugged into the client views",
"did": "user(1)",
"key": "tools.ozone.setting.client.externalLabelers",
"lastUpdatedBy": "user(1)",
"managerRole": "tools.ozone.team.defs#roleAdmin",
"scope": "instance",
"updatedAt": "1970-01-01T00:00:00.000Z",
"value": Object {
"dids": Array [
"user(0)",
],
},
},
Object {
"createdAt": "1970-01-01T00:00:00.000Z",
"createdBy": "user(1)",
"description": "This determines how each queue is balanced when sorted by oldest first",
"did": "user(1)",
"key": "tools.ozone.setting.client.queueHash",
"lastUpdatedBy": "user(1)",
"managerRole": "tools.ozone.team.defs#roleAdmin",
"scope": "instance",
"updatedAt": "1970-01-01T00:00:00.000Z",
"value": Object {
"val": 10.5,
},
},
Object {
"createdAt": "1970-01-01T00:00:00.000Z",
"createdBy": "user(1)",
"description": "This determines how many queues the client interface will show",
"did": "user(1)",
"key": "tools.ozone.setting.client.queues",
"lastUpdatedBy": "user(1)",
"managerRole": "tools.ozone.team.defs#roleAdmin",
"scope": "instance",
"updatedAt": "1970-01-01T00:00:00.000Z",
"value": Object {
"stratosphere": Object {
"name": "Stratosphere",
},
},
},
]
`;

@ -0,0 +1,310 @@
import { TestNetwork, SeedClient, basicSeed } from '@atproto/dev-env'
import AtpAgent, {
ToolsOzoneSettingListOptions,
ToolsOzoneSettingUpsertOption,
} from '@atproto/api'
import { ids } from '../src/lexicon/lexicons'
import { SettingScope } from '../dist/db/schema/setting'
import { forSnapshot } from './_util'
describe('ozone-settings', () => {
let network: TestNetwork
let agent: AtpAgent
let sc: SeedClient
const upsertOption = async (
setting: ToolsOzoneSettingUpsertOption.InputSchema,
callerRole: 'admin' | 'moderator' | 'triage' = 'admin',
) => {
const { data } = await agent.tools.ozone.setting.upsertOption(setting, {
encoding: 'application/json',
headers: await network.ozone.modHeaders(
ids.ToolsOzoneSettingUpsertOption,
callerRole,
),
})
return data
}
const removeOptions = async (
keys: string[],
scope: SettingScope,
callerRole: 'admin' | 'moderator' | 'triage' = 'admin',
) => {
await agent.tools.ozone.setting.removeOptions(
{ keys, scope },
{
encoding: 'application/json',
headers: await network.ozone.modHeaders(
ids.ToolsOzoneSettingRemoveOptions,
callerRole,
),
},
)
}
const listOptions = async (
params: ToolsOzoneSettingListOptions.QueryParams,
callerRole: 'admin' | 'moderator' | 'triage' = 'moderator',
) => {
const { data } = await agent.tools.ozone.setting.listOptions(params, {
headers: await network.ozone.modHeaders(
ids.ToolsOzoneSettingListOptions,
callerRole,
),
})
return data
}
beforeAll(async () => {
network = await TestNetwork.create({
dbPostgresSchema: 'ozone_settings',
})
agent = network.ozone.getClient()
sc = network.getSeedClient()
await basicSeed(sc)
await network.processAll()
})
afterAll(async () => {
await network.close()
})
describe('upsertOption', () => {
afterAll(async () => {
await removeOptions(
['tools.ozone.setting.upsertTest.labelers'],
'personal',
)
})
it('only allows managerRole to update instance settings', async () => {
await upsertOption({
scope: 'instance',
key: 'tools.ozone.setting.upsertTest.labelers',
value: { dids: ['did:plc:xyz'] },
description: 'triage users can not update this',
managerRole: 'tools.ozone.team.defs#roleModerator',
})
await expect(
upsertOption(
{
scope: 'instance',
key: 'tools.ozone.setting.upsertTest.labelers',
value: { noDids: 'test' },
description: 'triage users can not update this',
managerRole: 'tools.ozone.team.defs#roleModerator',
},
'triage',
),
).rejects.toThrow(/Not permitted/gi)
await upsertOption(
{
scope: 'instance',
key: 'tools.ozone.setting.upsertTest.labelers',
value: { noDids: 'test' },
description:
'My personal labelers that i want to use when browsing ozone',
managerRole: 'tools.ozone.team.defs#roleModerator',
},
'moderator',
)
const afterUpdatedByModerator = await listOptions(
{
scope: 'instance',
prefix: 'tools.ozone.setting.upsertTest.labelers',
},
'moderator',
)
expect(afterUpdatedByModerator.options[0].value?.['dids']).toBeFalsy()
expect(afterUpdatedByModerator.options[0].value?.['noDids']).toEqual(
'test',
)
await upsertOption(
{
scope: 'instance',
key: 'tools.ozone.setting.upsertTest.labelers',
value: { dids: 'test' },
description:
'My personal labelers that i want to use when browsing ozone',
managerRole: 'tools.ozone.team.defs#roleModerator',
},
'moderator',
)
const afterUpdatedByAdmin = await listOptions(
{
scope: 'instance',
prefix: 'tools.ozone.setting.upsertTest.labelers',
},
'admin',
)
expect(afterUpdatedByAdmin.options[0].value?.['noDids']).toBeFalsy()
expect(afterUpdatedByAdmin.options[0].value?.['dids']).toEqual('test')
})
})
describe('listOptions', () => {
beforeAll(async () => {
await Promise.all([
upsertOption({
scope: 'instance',
key: 'tools.ozone.setting.client.queues',
value: { stratosphere: { name: 'Stratosphere' } },
description:
'This determines how many queues the client interface will show',
managerRole: 'tools.ozone.team.defs#roleAdmin',
}),
upsertOption({
scope: 'instance',
key: 'tools.ozone.setting.client.queueHash',
value: { val: 10.5 },
description:
'This determines how each queue is balanced when sorted by oldest first',
managerRole: 'tools.ozone.team.defs#roleAdmin',
}),
upsertOption({
scope: 'instance',
key: 'tools.ozone.setting.client.externalLabelers',
value: { dids: ['did:plc:xyz'] },
description:
'List of external labelers that will be plugged into the client views',
managerRole: 'tools.ozone.team.defs#roleAdmin',
}),
])
})
afterAll(async () => {
await removeOptions(
[
'tools.ozone.setting.client.queues',
'tools.ozone.setting.client.queueHash',
'tools.ozone.setting.client.externalLabelers',
],
'instance',
)
})
it('returns all personal settings', async () => {
const result = await listOptions({ prefix: 'tools.ozone.setting.client' })
expect(result.options.length).toBe(3)
expect(forSnapshot(result.options)).toMatchSnapshot()
})
it('allows paginating options', async () => {
const params = { prefix: 'tools.ozone.setting.client', limit: 1 }
const pageOne = await listOptions(params)
const pageTwo = await listOptions({
...params,
cursor: pageOne.cursor,
})
const pageThree = await listOptions({
...params,
cursor: pageTwo.cursor,
})
const pageFour = await listOptions({
...params,
cursor: pageThree.cursor,
})
expect(pageFour.options.length).toBe(0)
expect(pageFour.cursor).toBeUndefined()
})
})
describe('removeOptions', () => {
afterAll(async () => {
await Promise.all([
removeOptions(['tools.ozone.setting.personal.labelers'], 'personal'),
removeOptions(
['tools.ozone.setting.only.mod', 'tools.ozone.setting.only.admin'],
'instance',
),
])
})
it('only allows the owner to delete personal setting', async () => {
await upsertOption({
scope: 'personal',
key: 'tools.ozone.setting.personal.labelers',
value: { dids: ['did:plc:xyz'] },
description:
'My personal labelers that i want to use when browsing ozone',
managerRole: 'tools.ozone.team.defs#roleOwner',
})
// one user can't remove personal setting of another
await removeOptions(
['tools.ozone.setting.personal.labelers'],
'personal',
'triage',
)
const list = await listOptions({ scope: 'personal' }, 'admin')
expect(list.options.length).toBe(1)
// the owner of the personal setting can remove their own setting
await removeOptions(['tools.ozone.setting.personal.labelers'], 'personal')
const listAfterRemoval = await listOptions({ scope: 'personal' }, 'admin')
expect(listAfterRemoval.options.length).toBe(0)
})
it('only allows managerRole to delete instance setting', async () => {
await Promise.all([
upsertOption({
scope: 'instance',
key: 'tools.ozone.setting.only.mod',
value: { dids: ['did:plc:xyz'] },
description: 'Triage mods can not manage these',
managerRole: 'tools.ozone.team.defs#roleModerator',
}),
upsertOption({
scope: 'instance',
key: 'tools.ozone.setting.only.admin',
value: { dids: ['did:plc:xyz'] },
description: 'Moderators or triage mods can not manage these',
managerRole: 'tools.ozone.team.defs#roleAdmin',
}),
])
await Promise.all([
removeOptions(['tools.ozone.setting.only.mod'], 'instance', 'triage'),
removeOptions(
['tools.ozone.setting.only.admin'],
'instance',
'moderator',
),
removeOptions(['tools.ozone.setting.only.admin'], 'instance', 'triage'),
])
const afterFailedAttempt = await listOptions(
{ scope: 'instance', prefix: 'tools.ozone.setting.only' },
'admin',
)
const keysAfterFailedAttempt = afterFailedAttempt.options.map(
(o) => o.key,
)
const keys = [
'tools.ozone.setting.only.mod',
'tools.ozone.setting.only.admin',
]
keys.forEach((key) => expect(keysAfterFailedAttempt).toContain(key))
await Promise.all([
removeOptions(['tools.ozone.setting.only.mod'], 'instance', 'admin'),
removeOptions(['tools.ozone.setting.only.admin'], 'instance', 'admin'),
])
const afterRemoval = await listOptions(
{ scope: 'instance', prefix: 'tools.ozone.setting.only' },
'admin',
)
expect(afterRemoval.options.length).toBe(0)
})
})
})

@ -180,6 +180,9 @@ import * as ToolsOzoneSetDeleteValues from './types/tools/ozone/set/deleteValues
import * as ToolsOzoneSetGetValues from './types/tools/ozone/set/getValues'
import * as ToolsOzoneSetQuerySets from './types/tools/ozone/set/querySets'
import * as ToolsOzoneSetUpsertSet from './types/tools/ozone/set/upsertSet'
import * as ToolsOzoneSettingListOptions from './types/tools/ozone/setting/listOptions'
import * as ToolsOzoneSettingRemoveOptions from './types/tools/ozone/setting/removeOptions'
import * as ToolsOzoneSettingUpsertOption from './types/tools/ozone/setting/upsertOption'
import * as ToolsOzoneSignatureFindCorrelation from './types/tools/ozone/signature/findCorrelation'
import * as ToolsOzoneSignatureFindRelatedAccounts from './types/tools/ozone/signature/findRelatedAccounts'
import * as ToolsOzoneSignatureSearchAccounts from './types/tools/ozone/signature/searchAccounts'
@ -2183,6 +2186,7 @@ export class ToolsOzoneNS {
moderation: ToolsOzoneModerationNS
server: ToolsOzoneServerNS
set: ToolsOzoneSetNS
setting: ToolsOzoneSettingNS
signature: ToolsOzoneSignatureNS
team: ToolsOzoneTeamNS
@ -2192,6 +2196,7 @@ export class ToolsOzoneNS {
this.moderation = new ToolsOzoneModerationNS(server)
this.server = new ToolsOzoneServerNS(server)
this.set = new ToolsOzoneSetNS(server)
this.setting = new ToolsOzoneSettingNS(server)
this.signature = new ToolsOzoneSignatureNS(server)
this.team = new ToolsOzoneTeamNS(server)
}
@ -2449,6 +2454,47 @@ export class ToolsOzoneSetNS {
}
}
export class ToolsOzoneSettingNS {
_server: Server
constructor(server: Server) {
this._server = server
}
listOptions<AV extends AuthVerifier>(
cfg: ConfigOf<
AV,
ToolsOzoneSettingListOptions.Handler<ExtractAuth<AV>>,
ToolsOzoneSettingListOptions.HandlerReqCtx<ExtractAuth<AV>>
>,
) {
const nsid = 'tools.ozone.setting.listOptions' // @ts-ignore
return this._server.xrpc.method(nsid, cfg)
}
removeOptions<AV extends AuthVerifier>(
cfg: ConfigOf<
AV,
ToolsOzoneSettingRemoveOptions.Handler<ExtractAuth<AV>>,
ToolsOzoneSettingRemoveOptions.HandlerReqCtx<ExtractAuth<AV>>
>,
) {
const nsid = 'tools.ozone.setting.removeOptions' // @ts-ignore
return this._server.xrpc.method(nsid, cfg)
}
upsertOption<AV extends AuthVerifier>(
cfg: ConfigOf<
AV,
ToolsOzoneSettingUpsertOption.Handler<ExtractAuth<AV>>,
ToolsOzoneSettingUpsertOption.HandlerReqCtx<ExtractAuth<AV>>
>,
) {
const nsid = 'tools.ozone.setting.upsertOption' // @ts-ignore
return this._server.xrpc.method(nsid, cfg)
}
}
export class ToolsOzoneSignatureNS {
_server: Server

@ -12520,6 +12520,225 @@ export const schemaDict = {
},
},
},
ToolsOzoneSettingDefs: {
lexicon: 1,
id: 'tools.ozone.setting.defs',
defs: {
option: {
type: 'object',
required: [
'key',
'value',
'did',
'scope',
'createdBy',
'lastUpdatedBy',
],
properties: {
key: {
type: 'string',
format: 'nsid',
},
did: {
type: 'string',
format: 'did',
},
value: {
type: 'unknown',
},
description: {
type: 'string',
maxGraphemes: 1024,
maxLength: 10240,
},
createdAt: {
type: 'string',
format: 'datetime',
},
updatedAt: {
type: 'string',
format: 'datetime',
},
managerRole: {
type: 'string',
knownValues: [
'tools.ozone.team.defs#roleModerator',
'tools.ozone.team.defs#roleTriage',
'tools.ozone.team.defs#roleAdmin',
],
},
scope: {
type: 'string',
knownValues: ['instance', 'personal'],
},
createdBy: {
type: 'string',
format: 'did',
},
lastUpdatedBy: {
type: 'string',
format: 'did',
},
},
},
},
},
ToolsOzoneSettingListOptions: {
lexicon: 1,
id: 'tools.ozone.setting.listOptions',
defs: {
main: {
type: 'query',
description: 'List settings with optional filtering',
parameters: {
type: 'params',
properties: {
limit: {
type: 'integer',
minimum: 1,
maximum: 100,
default: 50,
},
cursor: {
type: 'string',
},
scope: {
type: 'string',
knownValues: ['instance', 'personal'],
default: 'instance',
},
prefix: {
type: 'string',
description: 'Filter keys by prefix',
},
keys: {
type: 'array',
maxLength: 100,
items: {
type: 'string',
format: 'nsid',
},
description:
'Filter for only the specified keys. Ignored if prefix is provided',
},
},
},
output: {
encoding: 'application/json',
schema: {
type: 'object',
required: ['options'],
properties: {
cursor: {
type: 'string',
},
options: {
type: 'array',
items: {
type: 'ref',
ref: 'lex:tools.ozone.setting.defs#option',
},
},
},
},
},
},
},
},
ToolsOzoneSettingRemoveOptions: {
lexicon: 1,
id: 'tools.ozone.setting.removeOptions',
defs: {
main: {
type: 'procedure',
description: 'Delete settings by key',
input: {
encoding: 'application/json',
schema: {
type: 'object',
required: ['keys', 'scope'],
properties: {
keys: {
type: 'array',
minLength: 1,
maxLength: 200,
items: {
type: 'string',
format: 'nsid',
},
},
scope: {
type: 'string',
knownValues: ['instance', 'personal'],
},
},
},
},
output: {
encoding: 'application/json',
schema: {
type: 'object',
properties: {},
},
},
},
},
},
ToolsOzoneSettingUpsertOption: {
lexicon: 1,
id: 'tools.ozone.setting.upsertOption',
defs: {
main: {
type: 'procedure',
description: 'Create or update setting option',
input: {
encoding: 'application/json',
schema: {
type: 'object',
required: ['key', 'scope', 'value'],
properties: {
key: {
type: 'string',
format: 'nsid',
},
scope: {
type: 'string',
knownValues: ['instance', 'personal'],
},
value: {
type: 'unknown',
},
description: {
type: 'string',
maxLength: 2000,
},
managerRole: {
type: 'string',
knownValues: [
'tools.ozone.team.defs#roleModerator',
'tools.ozone.team.defs#roleTriage',
'tools.ozone.team.defs#roleAdmin',
],
},
},
},
},
output: {
encoding: 'application/json',
schema: {
type: 'object',
required: ['option'],
properties: {
option: {
type: 'ref',
ref: 'lex:tools.ozone.setting.defs#option',
},
},
},
},
},
},
},
ToolsOzoneSignatureDefs: {
lexicon: 1,
id: 'tools.ozone.signature.defs',
@ -13153,6 +13372,10 @@ export const ids = {
ToolsOzoneSetGetValues: 'tools.ozone.set.getValues',
ToolsOzoneSetQuerySets: 'tools.ozone.set.querySets',
ToolsOzoneSetUpsertSet: 'tools.ozone.set.upsertSet',
ToolsOzoneSettingDefs: 'tools.ozone.setting.defs',
ToolsOzoneSettingListOptions: 'tools.ozone.setting.listOptions',
ToolsOzoneSettingRemoveOptions: 'tools.ozone.setting.removeOptions',
ToolsOzoneSettingUpsertOption: 'tools.ozone.setting.upsertOption',
ToolsOzoneSignatureDefs: 'tools.ozone.signature.defs',
ToolsOzoneSignatureFindCorrelation: 'tools.ozone.signature.findCorrelation',
ToolsOzoneSignatureFindRelatedAccounts:

@ -0,0 +1,37 @@
/**
* GENERATED CODE - DO NOT MODIFY
*/
import { ValidationResult, BlobRef } from '@atproto/lexicon'
import { lexicons } from '../../../../lexicons'
import { isObj, hasProp } from '../../../../util'
import { CID } from 'multiformats/cid'
export interface Option {
key: string
did: string
value: {}
description?: string
createdAt?: string
updatedAt?: string
managerRole?:
| 'tools.ozone.team.defs#roleModerator'
| 'tools.ozone.team.defs#roleTriage'
| 'tools.ozone.team.defs#roleAdmin'
| (string & {})
scope: 'instance' | 'personal' | (string & {})
createdBy: string
lastUpdatedBy: string
[k: string]: unknown
}
export function isOption(v: unknown): v is Option {
return (
isObj(v) &&
hasProp(v, '$type') &&
v.$type === 'tools.ozone.setting.defs#option'
)
}
export function validateOption(v: unknown): ValidationResult {
return lexicons.validate('tools.ozone.setting.defs#option', v)
}

@ -0,0 +1,53 @@
/**
* GENERATED CODE - DO NOT MODIFY
*/
import express from 'express'
import { ValidationResult, BlobRef } from '@atproto/lexicon'
import { lexicons } from '../../../../lexicons'
import { isObj, hasProp } from '../../../../util'
import { CID } from 'multiformats/cid'
import { HandlerAuth, HandlerPipeThrough } from '@atproto/xrpc-server'
import * as ToolsOzoneSettingDefs from './defs'
export interface QueryParams {
limit: number
cursor?: string
scope: 'instance' | 'personal' | (string & {})
/** Filter keys by prefix */
prefix?: string
/** Filter for only the specified keys. Ignored if prefix is provided */
keys?: string[]
}
export type InputSchema = undefined
export interface OutputSchema {
cursor?: string
options: ToolsOzoneSettingDefs.Option[]
[k: string]: unknown
}
export type HandlerInput = undefined
export interface HandlerSuccess {
encoding: 'application/json'
body: OutputSchema
headers?: { [key: string]: string }
}
export interface HandlerError {
status: number
message?: string
}
export type HandlerOutput = HandlerError | HandlerSuccess | HandlerPipeThrough
export type HandlerReqCtx<HA extends HandlerAuth = never> = {
auth: HA
params: QueryParams
input: HandlerInput
req: express.Request
res: express.Response
}
export type Handler<HA extends HandlerAuth = never> = (
ctx: HandlerReqCtx<HA>,
) => Promise<HandlerOutput> | HandlerOutput

@ -0,0 +1,49 @@
/**
* GENERATED CODE - DO NOT MODIFY
*/
import express from 'express'
import { ValidationResult, BlobRef } from '@atproto/lexicon'
import { lexicons } from '../../../../lexicons'
import { isObj, hasProp } from '../../../../util'
import { CID } from 'multiformats/cid'
import { HandlerAuth, HandlerPipeThrough } from '@atproto/xrpc-server'
export interface QueryParams {}
export interface InputSchema {
keys: string[]
scope: 'instance' | 'personal' | (string & {})
[k: string]: unknown
}
export interface OutputSchema {
[k: string]: unknown
}
export interface HandlerInput {
encoding: 'application/json'
body: InputSchema
}
export interface HandlerSuccess {
encoding: 'application/json'
body: OutputSchema
headers?: { [key: string]: string }
}
export interface HandlerError {
status: number
message?: string
}
export type HandlerOutput = HandlerError | HandlerSuccess | HandlerPipeThrough
export type HandlerReqCtx<HA extends HandlerAuth = never> = {
auth: HA
params: QueryParams
input: HandlerInput
req: express.Request
res: express.Response
}
export type Handler<HA extends HandlerAuth = never> = (
ctx: HandlerReqCtx<HA>,
) => Promise<HandlerOutput> | HandlerOutput

@ -0,0 +1,58 @@
/**
* GENERATED CODE - DO NOT MODIFY
*/
import express from 'express'
import { ValidationResult, BlobRef } from '@atproto/lexicon'
import { lexicons } from '../../../../lexicons'
import { isObj, hasProp } from '../../../../util'
import { CID } from 'multiformats/cid'
import { HandlerAuth, HandlerPipeThrough } from '@atproto/xrpc-server'
import * as ToolsOzoneSettingDefs from './defs'
export interface QueryParams {}
export interface InputSchema {
key: string
scope: 'instance' | 'personal' | (string & {})
value: {}
description?: string
managerRole?:
| 'tools.ozone.team.defs#roleModerator'
| 'tools.ozone.team.defs#roleTriage'
| 'tools.ozone.team.defs#roleAdmin'
| (string & {})
[k: string]: unknown
}
export interface OutputSchema {
option: ToolsOzoneSettingDefs.Option
[k: string]: unknown
}
export interface HandlerInput {
encoding: 'application/json'
body: InputSchema
}
export interface HandlerSuccess {
encoding: 'application/json'
body: OutputSchema
headers?: { [key: string]: string }
}
export interface HandlerError {
status: number
message?: string
}
export type HandlerOutput = HandlerError | HandlerSuccess | HandlerPipeThrough
export type HandlerReqCtx<HA extends HandlerAuth = never> = {
auth: HA
params: QueryParams
input: HandlerInput
req: express.Request
res: express.Response
}
export type Handler<HA extends HandlerAuth = never> = (
ctx: HandlerReqCtx<HA>,
) => Promise<HandlerOutput> | HandlerOutput